# Profiling Cloudflare Workers CPU and memory with flamegraphs

> Capture on-demand CPU and heap profiles of deployed Cloudflare Workers and Durable Objects, read the flamegraph, and avoid the limits that trip teams up.

By Boris Tane, Founder of Polylane · Published October 10, 2026 · 11 min read
Canonical: https://polylane.com/learn/observability/cloudflare-workers-cpu-memory-profiling-flamegraph/

Since 9 October 2026, Cloudflare lets you capture an on-demand CPU or heap profile of a deployed Worker or a single Durable Object from the Observability tab, the cf CLI or the API, and read it as a flamegraph. Pick a version that is running and has traffic, set a duration from 1,000 to 50,000 ms, then look for the widest frames to find the functions burning CPU or allocating memory. Turn on upload_source_maps first so TypeScript frames show real function names.

Cloudflare announced on-demand CPU and memory profiling for Workers and Durable Objects on 9 October 2026. You can now point a profiler at a deployed Worker, or at one named Durable Object, let it sample for a few seconds, and get back a flamegraph that shows which functions used the CPU time or allocated the memory. You can start a capture from the dashboard, the `cf` CLI or the Cloudflare API, and each capture can be downloaded as a pprof file.

This guide covers what the feature measures, how it differs from the logs, metrics and local DevTools you use today, the exact steps for each entry point, and the limits that Cloudflare's announcement and docs list. It's new, so every detail here comes from Cloudflare's blog post and its Workers docs.

## What on-demand profiling for Workers is

A profile is a sample of what your code was doing over a short window. The runtime records call stacks at intervals and adds them up, so the functions that show up most often are the ones using the most of the resource.

Cloudflare offers two profile types:

- **CPU profile.** The default. It shows which functions used CPU time. Cloudflare's runtime starts a V8 CPU profiler that samples at a one millisecond interval.
- **Heap profile.** It shows which code allocated memory. It takes a stack trace every 512 kB allocated during the capture, so you see the chain of calls behind each sampled allocation.

On-demand means you start each capture yourself, and it runs against an isolate that is already loaded and serving real traffic. Cloudflare doesn't start a new isolate or call your code to produce the profile. The result is shown as an interactive flamegraph, with a table view, and can be downloaded as a pprof file for other tools.

## How a production profile differs from logs, metrics and local DevTools

Logs and metrics tell you that something is wrong. A Workers metric can show memory errors climbing or CPU time rising after a deploy. It can't tell you which function did it. A profile answers that second question.

Local DevTools profiling has existed for a while. You run `wrangler dev`, press `D` to open DevTools, and take a memory snapshot or a CPU profile against your local Worker. Cloudflare's own docs note the catch: it's hard to reproduce production behaviour locally, because the local Worker doesn't see the same kinds or number of requests. On-demand profiling closes that gap by sampling the isolate that handles real requests.

The two memory tools measure different things, and it matters which one you reach for:

- A **heap snapshot** in local DevTools shows what is in memory at one moment, so it's the tool for finding a leak such as a global string that keeps growing.
- A **heap profile** in production records allocations made during the capture window. Cloudflare's docs are explicit that it doesn't show whether that memory is still live, and it isn't a heap snapshot.

So a heap profile tells you which code allocates the most while traffic flows. If you need to know what is being retained, a local snapshot is still the right tool.

## How a capture finds the right isolate

Workers run in many data centres and can be replicated on one machine several times under load. Before a capture starts, Cloudflare has to work out which version you asked for, where it ran recently, whether its isolate is still loaded there, and whether that isolate belongs only to your account. That is why every request names a script and a version.

For a plain Worker, the runtime picks one running isolate of the requested version at random, because stateless Workers are interchangeable. For a Durable Object, you name the object, and the runtime routes the request to the machine that owns that actor and profiles the isolate running it.

Your Worker keeps serving requests while the profiler samples. For a CPU profile, the runtime takes the isolate lock only to start the profiler, releases it for the capture duration, takes it again to stop, and serialises the result outside the lock.

The diagram shows the path from a symptom to a fix.

```mermaid alt="From a Workers metric symptom to choosing a CPU or heap profile, targeting a Worker or Durable Object, and fixing"
graph TB
  A[Metrics show high CPU or memory] --> B{CPU time or memory?}
  B -->|CPU| C[Choose a CPU profile]
  B -->|Memory| D[Choose a Heap profile]
  C --> E{Durable Object?}
  D --> E
  E -->|yes| F[Pass namespace ID and actor ID]
  E -->|no| G[Pass Worker ID and running version]
  F --> H[Send real traffic during capture]
  G --> H
  H --> I[Read the widest frames and the table]
  I --> J[Fix, deploy, compare metrics again]
  style J fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

## Two things to set up before the first capture

### Upload source maps so frames have real names

If your Worker is written in TypeScript, or bundled and minified, the profile can show obfuscated function names. Cloudflare's announcement says to enable source maps first. Add this to your Wrangler config:

```toml
upload_source_maps = true
```

Wrangler then generates and uploads source maps when you run `wrangler deploy` or `wrangler versions deploy`. Source maps need Wrangler 3.46.0 or later, and the maximum source map size is 15 MB gzipped. Check your version with `wrangler --version`, and redeploy so the version you profile has maps attached.

### Make sure the version has traffic during the capture

A capture targets a recently active, already loaded isolate. Capturing doesn't send requests for you, and the log time range and filters in the dashboard don't affect it: the profile only records what happens during the capture duration. Send requests that exercise the code path you care about, before and during the capture. A version with little traffic may be hard to profile at all.

## Capturing a Worker profile step by step

### From the dashboard

1. Go to **Workers & Pages** and select your Worker.
2. Select **Observability**, then pick *Flamegraph* in the view dropdown.
3. In **Profile type**, choose *CPU* or *Heap*.
4. In **Duration (ms)**, enter a value from 1000 to 50000. The default is 10000.
5. In **Version**, choose *Latest* or a version in the active deployment.
6. Select **Capture profile**. The button reads **Capturing...** until it finishes, then the flamegraph renders.

### From the cf CLI

Install `cf` and sign in, then list the options:

```bash
cf workers versions profile --help
```

The command takes a positional version identifier and `--worker-id`. The version can be `latest`, a version UUID, or a UUID prefix of at least eight characters. It writes raw binary pprof to standard output:

```bash
cf workers versions profile latest \
  --worker-id "$WORKER_ID" \
  --duration-ms 5000 \
  --profile-type cpu > worker-cpu.pprof
```

### From the API

Use an API token with **Workers Scripts Read** permission or the **Content Read-Only** Workers role. The body takes a required `duration_ms` and an optional `profile_type` of `cpu` or `heap`. The response is a gzip-compressed pprof file:

```bash
curl --fail --silent --show-error --request POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/workers/$WORKER_ID/versions/latest/profile" \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{"duration_ms": 10000, "profile_type": "cpu"}' \
  --output worker-cpu.pprof.gz
```

Because the API is plain HTTP, you can script it or have a coding agent run it, which Cloudflare's announcement mentions directly.

## Profiling one Durable Object by its ID

Durable Objects have names, so you can profile one specific object wherever it runs. You need three identifiers:

- `$WORKER_ID`: the Worker that **owns** the Durable Object namespace. A Worker that only calls the namespace through a binding is the wrong target.
- `$NAMESPACE_ID`: the namespace ID.
- `$DURABLE_OBJECT_ID`: the instance ID, a 64-character hexadecimal string.

In the dashboard, go to **Durable Objects**, select the namespace, select the instance, then follow the same Observability and Flamegraph steps as for a Worker.

From the CLI, pass both `--namespace-id` and `--actor-id`:

```bash
cf workers versions profile latest \
  --worker-id "$WORKER_ID" \
  --duration-ms 5000 \
  --profile-type heap \
  --namespace-id "$NAMESPACE_ID" \
  --actor-id "$DURABLE_OBJECT_ID" > durable-object-heap.pprof
```

From the API, add `namespace_id` and `actor_id` to the body. The two fields are required together:

```json
{
  "duration_ms": 10000,
  "profile_type": "heap",
  "namespace_id": "<namespace id>",
  "actor_id": "<64-character instance id>"
}
```

The target instance must be active, running the selected version, and receiving traffic during the capture.

## Reading the flamegraph and the table

Each frame is a function in the captured call stacks. Callers sit above the functions they call. Frame width is proportional to CPU time or allocated memory, depending on the profile type. The horizontal position means nothing about time: the flamegraph isn't a timeline.

A reading routine that works:

1. Capture two or three profiles. Cloudflare suggests this so one odd window doesn't mislead you.
2. Find the widest frames. Click one to focus on its subtree, and use the breadcrumbs or **All frames** to go back.
3. Use **Search frames** to jump to a function name or source location.
4. Switch to **Table** and sort. **Self** is the cost of the frame alone, **Total** includes everything it calls, and **Percent of profile** is its share of the whole capture. Percentages stay relative to the whole capture even when you focus on a subtree.
5. Select **Download profile** to keep the raw pprof file, then open it in any pprof-compatible tool.

The CLI writes raw pprof, while the API returns it gzip-compressed. If a tool wants the uncompressed file, unpack it first:

```bash
gunzip worker-cpu.pprof.gz
```

## What Cloudflare found when it profiled its own Workers

The announcement includes two worked examples. They show the kind of finding a profile surfaces that metrics never could.

### A recursive JSON replacer in the R2 binding Worker

Cloudflare took a 50 second CPU profile of the Worker that implements the R2 binding. The widest frames, such as `decryptBlock` and `fillResponse`, were expected work. Sorting the table by samples pointed at `genericR2JsonReplacer`, which used over 5% of CPU time and called itself recursively. `JSON.stringify` already calls the replacer on every node, and the replacer walked the tree again, so a value nested five levels deep was processed five times. Removing the duplicate walk made the function 2.7x faster.

The same profile showed a function calling `registry.metrics()` twice. One call was about 1% of CPU time, so storing the first result in a variable saved that cost.

### Code that was meant to be disabled, still allocating

Another internal Worker had P999 memory around 133 MB against the 128 MB Worker memory limit, and was evicted often with an Exceeded Memory error. The errors and metrics pointed at memory but not at code. A heap profile opened in pprof showed Prometheus instrumentation behind roughly 66.7% of allocations. That code was believed to be off. It was only partly off, and still paid its memory cost. Removing it cut memory at every percentile.

**Figure 1. Memory of a Cloudflare internal Worker before and after removing Prometheus code**

| Metric | Before removing Prometheus code | After removing it |
| --- | ---: | ---: |
| P50 memory (MB) | 70 | 54 |
| P90 memory (MB) | 94 | 79 |
| P99 memory (MB) | 113 | 97 |
| P999 memory (MB) | 133 | 118 |

Figures from Cloudflare's 9 October 2026 announcement. The fix gave P999 about 10 MB of headroom under the 128 MB limit.

## Limits and gotchas that trip teams up

- **latest may be a version nobody runs.** In the CLI and API, `latest` is the most recently created version, which may not be deployed. If you uploaded a version without deploying it, pass the running version's UUID.
- **Quiet versions fail.** The API returns `No recent executions were found for this Worker.` or `The Worker has no loaded isolate in its recent execution locations.` when the target has no recent traffic. Send traffic and retry.
- **Start-up allocations are invisible.** The heap profile only sees allocations during the capture window, so memory allocated when the module loads won't appear. When we chased Durable Object memory resets in August, the isolate was over its limit before it served a single request, because module-load code had built the memory. A flat, high memory line at idle points at start-up cost, and a heap profile of live traffic won't show it. Profile the bundle's module evaluation locally for that case.
- **Allocations are not retention.** A function that allocates a lot and frees it quickly looks wide in a heap profile. Confirm leaks with a local heap snapshot.
- **You have to be there.** Captures are manual, so a short spike can be over before you start one.
- **Captures are rate limited.** The API returns HTTP `429`. Wait, and respect `Retry-After` when present.
- **Durable Object targeting is strict.** Use the owning Worker's ID and the 64-character instance ID, with both namespace and actor set.
- **Some environments can't profile.** `Worker profiling is unavailable in this environment.` and `This Worker does not support runtime profiling.` mean the target can't be profiled. `The Worker runtime could not start profiling. Please try again.` means retry.
- **Minified names.** Without source maps, TypeScript Workers show obfuscated frames. Set `upload_source_maps` and redeploy.

## Continuous profiling is the next step

Cloudflare names the two biggest limits itself: you must start each session, and the memory profiler misses allocations outside the window. It says it is already working on continuous profiling, which would capture samples automatically so you can browse profiles for rare events after the fact. Until it ships, the practical habit is to capture a CPU and a heap profile of each important Worker under normal load, keep the pprof files, and compare against them after a regression.

## Fixing Durable Object memory errors with Polylane

Polylane detects production issues and fixes them by opening pull requests. It connects to Cloudflare, so when a Durable Object runs out of memory it works out why, and the fix arrives ready for your team's normal review.

Running on Cloudflare? See [how Polylane monitors Cloudflare in production](https://polylane.com/for/cloudflare/).

## Common questions

**When did Cloudflare launch on-demand profiling for Workers?**

Cloudflare announced it on 9 October 2026 in the post Introducing on-demand CPU and memory profiling with flamegraphs for Workers and Durable Objects. It covers both Workers and Durable Objects, from the dashboard, the cf CLI and the API.

**How long can a Workers profile run?**

The duration ranges from 1,000 to 50,000 milliseconds. The dashboard default is 10,000 ms, and the CLI and API take the value as --duration-ms or duration_ms.

**What is the difference between a CPU profile and a Heap profile?**

A CPU profile shows which functions used CPU time and samples at a one millisecond interval. A Heap profile takes a stack trace every 512 kB allocated during the capture, so it shows which code allocates memory. It does not show whether that memory is still retained.

**Why does my capture fail with No recent executions were found?**

The capture only attaches to an isolate that is already loaded and recently active, and it doesn't send requests itself. Send traffic to the version you selected, check that latest is a deployed version, then retry.

**How do I profile a single Durable Object?**

Pass the ID of the Worker that owns the namespace, the namespace ID, and the 64-character hexadecimal instance ID. In the CLI that's --worker-id, --namespace-id and --actor-id; in the API it's namespace_id and actor_id, which must be set together.

**What API permission does profiling need?**

An API token with Workers Scripts Read permission, or the Content Read-Only Workers role. The endpoint is a POST to the account's workers/workers/WORKER_ID/versions/VERSION/profile path and returns a gzip-compressed pprof file.

**Why are the function names in my flamegraph unreadable?**

Your bundle is minified and the version has no source maps. Set upload_source_maps to true in your Wrangler config, use Wrangler 3.46.0 or later, and redeploy before capturing.

**Will a heap profile show memory allocated at start-up?**

No. The memory profiler only shows allocations made during the capture window, so module-load allocations are missed. Cloudflare says continuous profiling is in progress to help capture rarer events.

## Sources

- [Introducing on-demand CPU and memory profiling with flamegraphs for Workers and Durable Objects (Cloudflare Blog)](https://blog.cloudflare.com/workers-on-demand-profiling/)
- [Profiling in production (Cloudflare Workers docs)](https://developers.cloudflare.com/workers/observability/profiling-in-production/)
- [Source maps and stack traces (Cloudflare Workers docs)](https://developers.cloudflare.com/workers/observability/source-maps/)
- [Profiling Memory with DevTools (Cloudflare Workers docs)](https://developers.cloudflare.com/workers/observability/dev-tools/memory-usage/)
- [Polylane full content, including How we fixed our Cloudflare Durable Objects memory exceeded errors](https://polylane.com/llms-full.txt)

## About the author

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

## Related

- [Fix Cloudflare “Error 1102: Worker exceeded resource limits”](https://polylane.com/learn/troubleshooting/cloudflare-error-1102-worker-exceeded-resource-limits/): Why a Cloudflare Worker returns Error 1102, how to tell a CPU time overrun from a 128 MB memory overrun, and the code and Wrangler changes that fix each.
- [How to Debug Cloudflare Workers Errors: Logs, Traces and Error Codes](https://polylane.com/learn/troubleshooting/how-to-debug-cloudflare-workers-errors/): Debug Cloudflare Workers errors step by step: read 1101 and 1102 codes, enable Workers Logs and source maps, use wrangler tail, DevTools and local traces.
- [Cloudflare Workers error 1101: causes and how to fix it](https://polylane.com/learn/troubleshooting/cloudflare-workers-error-1101/): Error 1101 means your Cloudflare Worker threw an uncaught JavaScript exception. Find the exception in logs, match it to its cause, fix it and roll back fast.
- [Fix “Durable Object reset because its code was updated”](https://polylane.com/learn/troubleshooting/cloudflare-durable-object-reset-because-its-code-was-updated/): Why a deploy makes Cloudflare Durable Objects throw “reset because its code was updated”, and how to retry safely, keep clients connected and lose no state.
- [Workers Issues: route Cloudflare errors to coding agents](https://polylane.com/learn/incident-response/cloudflare-workers-issues-error-monitoring-agents/): Cloudflare's new Workers Issues groups exceptions and 5xx errors and sends them to Claude Code, Cursor, Devin or a webhook. Setup, automations and limits.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
