---
title: "Cloudflare Workers: from a request to a running application"
description: "Understand the Workers runtime, build a bilingual reading-time API with Hono, and follow it through local development, deployment, bindings, costs, and debugging."
author: "Omar Albeik"
date: 2026-09-21
type: tutorial
topics: [infrastructure]
language: en
reading_time_minutes: 19
canonical_url: https://omaralbeik.com/en/blog/series/cloudflare/understanding-cloudflare-workers
translation_url: https://omaralbeik.com/ar/blog/series/cloudflare/understanding-cloudflare-workers
source_url: https://omaralbeik.com/en/blog/series/cloudflare/understanding-cloudflare-workers.md
---

# Cloudflare Workers: from a request to a running application

In the [DNS guide](/en/blog/series/cloudflare/understanding-cloudflare-dns),
we connected a domain to an application. Now we need something to answer
the request.

A Cloudflare Worker can be that application. It receives a request, runs
your code, and returns a response. You can use it for a small API, a form
handler, or the server side of a website. This site runs on Workers too.

We'll build a bilingual reading-time API with Hono. Send it some English or Arabic text, and it returns a word count and a reading-time estimate. It's the kind of small tool you could put behind a blog editor. It needs no database, external API key, or AI model.

You'll need some JavaScript knowledge, a terminal, and a current Node.js LTS
installation with npm. TypeScript annotations are explained along the way.
A Cloudflare account is needed for deployment; a domain is optional.

- [Understand the runtime](#what-runs-inside-a-worker).
- [Build and test the API](#create-the-project).
- [Deploy and connect a domain](#give-it-a-public-url).
- [Add configuration, secrets, and storage](#what-belongs-in-env).
- [Understand limits and costs](#cpu-time-isnt-the-time-the-reader-waits).
- [Find a failing request](#follow-a-request-through-the-logs).

## What runs inside a Worker?

Your Worker is server-side code. The visitor receives its response, not its
source. Cloudflare manages the machines running it; you deploy the
application and configure what it can access.

JavaScript Workers run in **V8 isolates**: separate execution environments
within the runtime. An isolate can handle multiple requests and may later
be discarded. You aren't given a permanent process to keep alive or a
machine whose memory you can rely on between visits.

Suppose you add a global visit counter. It seems to work locally: refresh
the page and the number goes up. In production, another isolate can start
its own counter, and a replacement isolate can start over. Cloudflare's
[runtime explanation](https://developers.cloudflare.com/workers/reference/how-workers-works/)
describes this execution model.

**Same code. Separate memory.**

Imagine a visit counter stored in a global variable, starting at zero.

1. **Isolate A — 2**: Requests 1 and 2 arrive here. Its counter reaches 2.
2. **Isolate B — 1**: Request 3 reaches another isolate. Its counter starts at zero.
3. **A fresh isolate — 1**: Request 4 arrives after A is replaced. The new counter starts at zero.

Four visits, no reliable total. This is one possible sequence; isolate reuse and request placement are not guaranteed.

*An in-memory counter belongs to one isolate. It cannot tell you how many requests the whole application has handled.*

Keep request-specific data inside the handler. Immutable constants can live
at module scope; data that must survive needs persistent storage.

Workers uses familiar web APIs: `Request`, `Response`, `URL`, and `fetch()`.
There is no page or DOM here. An HTTP request enters your exported `fetch`
handler, and the returned `Response` becomes the HTTP response.

```ts
export default {
  async fetch(request, env, ctx): Promise<Response> {
    return new Response("The Worker answered.");
  },
} satisfies ExportedHandler<CloudflareBindings>;
```

The [handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/)
receives three useful objects:

- `request` contains the URL, method, headers, and body sent by the client.
- `env` exposes configured variables, secrets, and service bindings.
- `ctx` controls work associated with this invocation, including work that
  can continue briefly after the response.

`ExportedHandler<CloudflareBindings>` checks the handler's TypeScript shape. We'll generate
`CloudflareBindings` from the configuration shortly. The types disappear when the code is
built; Cloudflare runs JavaScript.

**From a URL to an answer**

Follow a successful request to our reading-time API.

1. **Client — POST /api/reading-time**: The client sends text, language, and an optional reading speed as JSON.
2. **Cloudflare — Select the Worker**: The configured hostname determines which Worker receives the request.
3. **Your handler — Validate → count**: Hono selects the route. The handler validates JSON and counts word-like segments.
4. **Back to the client — 200 · application/json**: Return the status, headers, and JSON body. Invalid input takes an earlier exit with 400.

This Worker creates the answer itself. No upstream application server or database is involved.

*For this API, the Worker produces the response itself. There is no separate application server behind it.*

The handler named `fetch` receives a request. Calling `fetch()` *inside*
the handler makes an outgoing request. Our example doesn't need that second
step: the runtime can calculate the answer itself.

This also explains where Workers fits alongside DNS. DNS helps the client
find the service; HTTP routing selects the Worker; your code decides what
`/api/reading-time` means. Changing a DNS record doesn't create an API endpoint.

### Can I use npm packages?

Yes, provided their dependencies work in the Workers runtime. Node.js
compatibility supports many built-in modules, but some APIs are partial or
import-only stubs. A successful installation doesn't prove that a package
will run.

For compatibility dates from **August 4, 2026**, Node.js compatibility is
enabled by default, including the date used below. Older examples often add a `nodejs_compat` flag;
Cloudflare's [current compatibility guide](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)
explains the date-dependent behavior. Test the actual code path you need,
especially when a library expects operating-system features.

## Create the project

The native handler above is the foundation. [Hono](https://hono.dev/docs/getting-started/cloudflare-workers) adds named routes and middleware around it. Workers still supplies the runtime, deployment, and bindings. We can write the application's behavior without manually dispatching every URL.

From your projects directory, run:

```bash
npm create cloudflare@latest -- reading-api
```

Choose **Hello World**, **Worker only**, and **TypeScript**, and decline deployment for now. The [Cloudflare project generator](https://developers.cloudflare.com/workers/get-started/guide/) installs Wrangler. Then add Hono:

```bash
cd reading-api
npm install hono
```

We will use two source files: `src/analyze.ts` for the text calculation and `src/index.ts` for HTTP handling. Replace `wrangler.jsonc` with:

```jsonc title="wrangler.jsonc"
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "reading-api",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-21",
  "workers_dev": true,
  "vars": {
    "SERVICE_NAME": "reading-api",
    "DEFAULT_WPM": 200
  },
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  }
}
```

`name` identifies the Worker; `main` is its entry file. `workers_dev` enables a public hostname when you deploy. `observability` stores logs, selecting every request with the sampling rate shown here. These settings don't publish anything yet.

The compatibility date selects runtime behavior, not npm package versions. Start with today's date and test later date changes. If Wrangler cannot support it, update Wrangler in the project. Your lockfile keeps dependency versions reproducible.

`DEFAULT_WPM` is a numeric configuration value. **200 words per minute is a starting assumption for this example, not a measured reading speed for English or Arabic.** Clients can override it within the application's 80–600 range. That range is a product choice, not a Cloudflare limit.

Generate the binding types:

```bash
npx wrangler types --env-interface CloudflareBindings
```

Regenerate them when configuration changes. Hono will use this generated interface to check `c.env`, including the numeric `DEFAULT_WPM` value.

## Try the reading-time tool

Paste a paragraph, choose its language, and adjust the assumed pace. The estimate updates as you type. Expand the API details to see the JSON a client would send and receive.

**How long would this take to read?**

Count words in English or Arabic, then estimate reading time using a pace you choose.

1. **POST /api/reading-time? — 404 / 405**: Unknown paths return 404; other methods on this path return 405 with Allow: POST.
2. **Body within 64 KiB? — 413**: The body-limit middleware rejects oversized requests before parsing JSON.
3. **JSON content type, valid JSON and fields? — 415 / 400**: Wrong content type returns 415. Invalid JSON, text, language, or speed returns 400.
4. **Count words and estimate time — 200**: Return language, words, wordsPerMinute, seconds, and minutes. No database is needed.

The playground only runs the text calculation locally. The server also checks HTTP routing, content type, and body size. A default of 200 words per minute is an adjustable assumption, not a measured average.

*The playground runs the article's analysis function in your browser. The expandable diagram describes the server's checks; no HTTP request is sent by this demo.*

The language selects word-segmentation rules; it doesn't translate the text or verify which language you typed. Mixed Arabic and English text is allowed. Reading time also depends on difficulty, familiarity, and pauses, so the interface should present it as an estimate.

## Count words before handling HTTP

Create `src/analyze.ts`:

```ts title="src/analyze.ts"
export function analyzeReading(input: unknown, defaultWpm = 200) {
  if (!input || typeof input !== "object" || Array.isArray(input)) {
    return { ok: false as const, error: "Send a JSON object." };
  }

  const data = input as Record<string, unknown>;

  if (typeof data.text !== "string" || !data.text.trim()) {
    return { ok: false as const, error: "Add some text to analyze." };
  }

  if (data.text.length > 10_000) {
    return {
      ok: false as const,
      error: "Keep text within 10,000 UTF-16 code units.",
    };
  }

  if (data.language !== "en" && data.language !== "ar") {
    return { ok: false as const, error: "Choose en or ar." };
  }

  const wordsPerMinute =
    data.wordsPerMinute === undefined
      ? defaultWpm
      : data.wordsPerMinute;

  if (
    typeof wordsPerMinute !== "number" ||
    !Number.isInteger(wordsPerMinute) ||
    wordsPerMinute < 80 ||
    wordsPerMinute > 600
  ) {
    return {
      ok: false as const,
      error: "Use a whole-number reading speed from 80 to 600.",
    };
  }

  const segmenter = new Intl.Segmenter(data.language, {
    granularity: "word",
  });
  let words = 0;

  for (const segment of segmenter.segment(data.text)) {
    if (segment.isWordLike) {
      words++;
    }
  }

  if (words === 0) {
    return {
      ok: false as const,
      error: "Add text containing at least one word.",
    };
  }

  return {
    ok: true as const,
    value: {
      language: data.language,
      words,
      wordsPerMinute,
      seconds: Math.ceil((words / wordsPerMinute) * 60),
      minutes: Math.ceil(words / wordsPerMinute),
    },
  };
}
```

[`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter) identifies word boundaries using the chosen locale. We count segments marked `isWordLike`; punctuation and spaces alone don't count. This isn't Arabic morphological analysis: attached prefixes aren't necessarily separate words. Runtime language-data versions can also produce small differences in segmentation.

The function accepts `unknown` because a TypeScript annotation cannot validate incoming JSON. It checks the object, text, language, and reading speed before doing the calculation. The text limit uses JavaScript's `text.length`: 10,000 UTF-16 code units, not bytes or visible characters. Emoji, for example, may use more than one unit.

`seconds` and `minutes` are independently rounded up. Five words at 200 words per minute gives 2 seconds or 1 minute rounded up; display the unit that suits your interface. There is no artificial one-minute minimum on the seconds field.

## Add the Hono routes

Replace `src/index.ts` with:

```ts title="src/index.ts"
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { analyzeReading } from "./analyze";

const app = new Hono<{
  Bindings: CloudflareBindings;
  Variables: {
    requestId: string;
  };
}>();

app.use("*", async (c, next) => {
  const requestId = crypto.randomUUID();

  c.set("requestId", requestId);
  c.header("X-Request-Id", requestId);
  c.header("Cache-Control", "no-store");

  await next();

  console.log({
    event: "reading_api_response",
    requestId,
    status: c.res.status,
  });
});

app.get("/health", (c) => {
  return c.json({ status: "ok", service: c.env.SERVICE_NAME });
});

app.post(
  "/api/reading-time",
  bodyLimit({
    maxSize: 64 * 1024,
    onError: (c) =>
      c.json({ error: "Keep the request body within 64 KiB." }, 413),
  }),
  async (c) => {
    const mediaType = c.req
      .header("Content-Type")
      ?.split(";")[0]
      ?.trim()
      .toLowerCase();

    if (mediaType !== "application/json") {
      return c.json(
        { error: "Use Content-Type: application/json." },
        415,
      );
    }

    let input: unknown;

    try {
      input = await c.req.json();
    } catch (error) {
      if (error instanceof SyntaxError) {
        return c.json({ error: "Send valid JSON." }, 400);
      }

      throw error;
    }

    const defaultWpm = c.env.DEFAULT_WPM;

    if (
      !Number.isInteger(defaultWpm) ||
      defaultWpm < 80 ||
      defaultWpm > 600
    ) {
      throw new Error("Invalid DEFAULT_WPM configuration.");
    }

    const result = analyzeReading(input, defaultWpm);

    if (!result.ok) {
      return c.json({ error: result.error }, 400);
    }

    return c.json(result.value);
  },
);

app.all("/api/reading-time", (c) => {
  c.header("Allow", "POST");
  return c.json({ error: "Use POST." }, 405);
});

app.notFound((c) => c.json({ error: "Not found." }, 404));

app.onError((error, c) => {
  console.error({
    event: "reading_api_error",
    requestId: c.get("requestId"),
    name: error.name,
  });

  return c.json({ error: "Internal server error." }, 500);
});

export default app;
```

Read this in three parts. The first middleware assigns a fresh request ID, sets `no-store`, and logs the final status after `await next()`. It doesn't log submitted text. Then `GET /health` checks that the application responds, while `POST /api/reading-time` handles analysis. Finally, explicit fallbacks cover wrong methods, unknown routes, and unexpected errors.

Hono's [context](https://hono.dev/docs/api/context) gives each request its own `c`. Use `c.req` for the request, `c.env` for Workers bindings, and `c.json()` to return JSON. `Variables` describes request-scoped values set with `c.set()`; it isn't another place to configure environment bindings.

The [body-limit middleware](https://hono.dev/docs/middleware/builtin/body-limit) bounds the body at 64 KiB before we parse JSON. Its limit includes the JSON syntax and encoded text. It uses Content-Length when present and checks a stream when it isn't. This byte limit is separate from the text-length rule. Oversized bodies return `413`; other validation runs only after that check passes.

The route requires `application/json`, otherwise it returns `415`. Broken JSON syntax and invalid fields return `400`. The `app.all()` fallback returns `405` with `Allow: POST` for other methods on the analysis path. Unknown paths return `404`. Hono handles a `HEAD` request to the health route without a body; the analysis route remains POST-only.

`app.onError()` gives unexpected application exceptions a generic `500` and logs their name with the request ID. This deliberately avoids exposing or recording user text. Platform failures, such as exhausting CPU, can still happen outside application error handling. Keep reading for the runtime limits.

## Test the API locally

```bash
npx wrangler dev --local
```

Wrangler uses the Workers runtime, `workerd`, locally. `--local` disables remote binding connections; it does not block outgoing `fetch()` calls to real services. Our example makes no such calls. The [local development guide](https://developers.cloudflare.com/workers/local-development/) explains the distinction.

In another terminal, send a paragraph:

```bash
curl -i 'http://localhost:8787/api/reading-time' \
  -H 'Content-Type: application/json' \
  --data '{"text":"Small APIs can be useful.","language":"en"}'
```

Expect `200`, `Cache-Control: no-store`, an `X-Request-Id`, and this JSON:

```json title="Response"
{
  "language": "en",
  "words": 5,
  "wordsPerMinute": 200,
  "seconds": 2,
  "minutes": 1
}
```

To use Arabic, set `language` to `ar` and send Arabic text. Add `"wordsPerMinute": 150` to override the configured default. Then try the failure cases:

```bash
# Health check: 200
curl -i 'http://localhost:8787/health'

# Empty text: 400
curl -i 'http://localhost:8787/api/reading-time' \
  -H 'Content-Type: application/json' \
  --data '{"text":"","language":"en"}'

# Invalid JSON: 400
curl -i 'http://localhost:8787/api/reading-time' \
  -H 'Content-Type: application/json' \
  --data '{'

# Wrong content type: 415
curl -i 'http://localhost:8787/api/reading-time' \
  --data 'hello'

# Wrong method: 405, with Allow: POST
curl -i 'http://localhost:8787/api/reading-time'

# Unknown route: 404
curl -i 'http://localhost:8787/missing'
```

For a real article, save the JSON payload in `request.json` and use `--data-binary @request.json` with the same content-type header. This preserves UTF-8 text and avoids shell-quoting problems. Also test an unsupported language, punctuation-only text, a fractional or out-of-range speed, and a body larger than 64 KiB before relying on the endpoint.

This is a public API with no authentication. `no-store` asks caches not to retain its responses; it isn't an access-control mechanism. If a frontend on another origin calls it, add a CORS policy for that client. CORS governs browser access to responses, not caller identity.

## Give it a public URL

**When does the code become public?**

The first three steps run on your machine. The fourth publishes the Worker.

1. **01 · Edit — src/index.ts + wrangler.jsonc**: Your handler and its configuration live in the project.
2. **02 · Run locally — wrangler dev --local**: Try real HTTP requests against a local Workers runtime.
3. **03 · Check the bundle — wrangler deploy --dry-run**: Build the deployment bundle without publishing a version.
4. **04 · Publish — wrangler deploy**: Upload the Worker and make the deployment serve traffic. Check its printed URL.

A dry run checks the bundle. Only a request to the deployed URL checks the live routing and configuration.

*Editing, local requests, and a deployment dry run leave the public Worker unchanged. Deploying is the step that makes the new code live.*

First check the types and the deployment bundle:

```bash
npx tsc --noEmit
npx wrangler deploy --dry-run
```

The dry run builds the Worker without publishing it. It cannot prove that
production permissions, domains, or future remote dependencies are correct.

When you're ready to publish the example, sign in and deploy:

```bash
npx wrangler login
npx wrangler deploy
```

Wrangler prints the deployed URL, typically in the form
`https://reading-api.<your-subdomain>.workers.dev`. Use the actual URL from the
command output, use `/health` for a quick check, then repeat the POST examples against
`/api/reading-time` at that hostname. You don't need to buy a domain to reach this point.

Local testing exercises the code. Testing the deployed URL also checks
that you're reaching the intended account, Worker, and configuration.

### Connect your own domain

For this API, the Worker is the application's origin. A **Custom Domain**
connects an entire hostname to it. With an active domain on Cloudflare, add
this top-level property to `wrangler.jsonc`, substituting a hostname you own:

```jsonc title="wrangler.jsonc · merge into existing config"
{
  "routes": [
    {
      "pattern": "reading.example.com",
      "custom_domain": true
    }
  ]
}
```

Deploy again. Cloudflare manages the DNS record and certificate for the
[Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/).
Use an unused hostname: an existing CNAME can conflict with this setup.
Hono still selects the route, so send your POST request to
`https://reading.example.com/api/reading-time`.

A **Route** serves a different setup: your Worker runs in front of an
existing proxied origin, for example to inspect a request before forwarding
it to a server you already operate. A route matches a URL pattern and
requires the corresponding proxied DNS record. The
[routing guide](https://developers.cloudflare.com/workers/configuration/routing/routes/)
covers that arrangement. Our reading-time API doesn't need another origin.

## What belongs in env?

We've used `c.env.SERVICE_NAME` for a public label and `c.env.DEFAULT_WPM` for the default reading pace. As an application grows,
`env` can also expose secrets and connections to services. These have
different jobs.

**What does env give your code?**

The reading-time API uses the first entry. The others show how an application could grow.

1. **c.env.SERVICE\_NAME — A value**: A non-secret label from vars: reading-api.
2. **c.env.PROVIDER\_API\_KEY — A secret**: A credential your code can use when calling an external provider.
3. **c.env.DB — A database binding**: An interface to one configured D1 database, with permission to use it.
4. **c.env.UPLOADS — A bucket binding**: An interface to one configured R2 bucket. The files live in R2.

A resource binding provides access to the resource. It does not copy a database or bucket into the Worker's memory.

*Variables and secrets provide values. Resource bindings provide interfaces to configured services; the data stays in those services.*

A [binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/)
gives your Worker a configured interface to a resource. With a D1 binding
named `DB`, for instance, your code can query through `c.env.DB`. You don't
need to put a Cloudflare account API token in the handler to use that
binding. Choose the resource, configure the binding, then regenerate types.

The reading-time API needs no stored data. If it grew into an editorial tool,
the choice would depend on what it saves:

- **D1** for records you want to query with SQL, such as saved articles.
- **KV** for reads by key where delayed visibility of updates is acceptable.
- **R2** for files, such as exported documents.
- **Durable Objects** when operations need coordination around one object,
  such as editors updating the same document.

These are starting points, not interchangeable ways to make a global
variable persistent. Cloudflare's [storage comparison](https://developers.cloudflare.com/workers/platform/storage-options/)
is a useful reference; later parts of this series will work through the
tradeoffs in detail.

### Keep credentials out of the configuration file

If you later call an external provider, add its credential as a secret:

```bash
npx wrangler secret put PROVIDER_API_KEY
```

Wrangler prompts for the value. Your code then reads `c.env.PROVIDER_API_KEY`.
For local development, use a `.dev.vars` file and add `.dev.vars*` and
`.env*` to `.gitignore`. Local secret files are not uploaded as production
secrets. The [secrets guide](https://developers.cloudflare.com/workers/configuration/secrets/)
explains both setups.

Skip this step for the reading-time API; it has no credentials.

### Separate staging from production when you need it

[Wrangler environments](https://developers.cloudflare.com/workers/wrangler/environments/)
let you deploy the same project as separate Workers. For example, add an
`env` property alongside the existing configuration:

```jsonc title="wrangler.jsonc · merge into existing config"
{
  "env": {
    "staging": {
      "routes": [],
      "vars": {
        "SERVICE_NAME": "reading-api-staging",
        "DEFAULT_WPM": 200
      }
    }
  }
}
```

```bash
npx wrangler dev --local --env staging
npx wrangler deploy --env staging
```

The deployment command creates `reading-api-staging`. Its empty `routes` list
keeps it from inheriting the production domain added earlier; test it on
its own `workers.dev` URL. The original top-level configuration still
targets `reading-api`. Keep the intended target explicit in commands that
change a deployment.

Variables and resource bindings aren't inherited from the top level;
declare them for each environment. Separate Worker names alone don't
isolate data if both bindings point at the same database. Set secrets for
the intended environment too, using `--env staging` where appropriate.

**Separate the data as well as the names**

If you later add D1, give each environment its own database. Both Workers can use the binding name c.env.DB.

1. **Staging — reading-api-staging**: c.env.DB connects to a test database: reading-api-staging-db.
2. **Production — reading-api**: c.env.DB connects to a live database: reading-api-production-db.

Database names here are illustrative; the reading-time API has no database yet. Point both bindings at the same database and both Workers can change the same data.

*The same binding name can refer to different resources. Choose the database separately for each environment.*

## CPU time isn't the time the reader waits

Imagine an endpoint that validates input, waits for a database, then
formats a response. It might spend 3 ms executing validation, 200 ms waiting,
and 2 ms formatting. That's about 205 ms inside the handler, but only 5 ms
of CPU work. These are illustrative numbers, not a benchmark of our API.

**Two clocks, one request**

Keep the code's work fixed. Change only the time spent waiting for data.

1. **Validate — 3 ms CPU**: Execute the input checks.
2. **Wait for data — 200 ms waiting**: Waiting on the network adds elapsed time, without adding Worker CPU time.
3. **Format — 2 ms CPU**: Build the response.

At the default 200 ms wait: 5 ms CPU, about 205 ms elapsed in the handler. Illustrative values; not a benchmark. Other network overhead is omitted.

*Network waiting affects response time even when it doesn't count as Worker CPU time. A low CPU figure alone doesn't mean a fast application.*

That distinction matters when choosing a plan. As of September 21, 2026,
the [Workers limits](https://developers.cloudflare.com/workers/platform/limits/)
for ordinary HTTP handlers include:

| Limit | Workers Free | Workers Paid |
| --- | --- | --- |
| Incoming requests | 100,000 per day | Usage-based billing |
| CPU per request | 10 ms | 30 seconds by default; configurable up to 5 minutes |
| Memory per isolate | 128 MB | 128 MB |

The daily Free allowance is account-wide and resets at midnight UTC. Network
waiting doesn't consume CPU time. Request lifetime is a separate concern:
when a client disconnects, unfinished work can be canceled.

Memory is also shared by requests in an isolate. For large payloads,
[streaming](https://developers.cloudflare.com/workers/runtime-apis/streams/)
lets you pass data through without buffering the entire body. This API returns a small object, so Hono’s `c.json()` is appropriate.

### Nearby code can still wait for a distant database

By default, Workers runs near where the request reaches Cloudflare.
That helps our API because it needs no remote data. An application making
several round trips to a database elsewhere has a different bottleneck.

[Placement](https://developers.cloudflare.com/workers/configuration/placement/)
can run the Worker closer to its backend when that reduces overall latency.
Measure the request and its dependencies before moving it: distance to the
user is only one part of response time.

### Work after the response needs a lifetime

If a result depends on an asynchronous operation, `await` it. For optional,
short work that can finish after the response, the handler's `ctx` offers
`waitUntil()`. In Hono, access it as `c.executionCtx.waitUntil()`.

For HTTP handlers, [the documented allowance](https://developers.cloudflare.com/workers/runtime-apis/context/)
is up to 30 seconds after the response is sent or the client disconnects,
shared across that invocation's `waitUntil()` tasks. Starting a promise and
forgetting it can leave the work unfinished.

Use a Queue when the task needs durable delivery and retries. Use a
[Workflow](https://developers.cloudflare.com/workflows/) for a process
that needs durable steps and waits. Returning “saved” before
an unawaited database write finishes is a bug, even if it worked in a
local test.

## What would this cost?

The Free plan is a reasonable place to try the API. Measure its CPU usage
and traffic before deciding whether it needs a paid plan.

At the time of writing, the [Workers Standard pricing](https://developers.cloudflare.com/workers/platform/pricing/)
starts at **$5 USD per account per month**, including 10 million requests
and 30 million CPU milliseconds. Additional usage costs $0.30 per million
requests and $0.02 per million CPU milliseconds.

**How 12 million requests add up to $5.96**

Assume 4 ms of CPU per request: 12 million × 4 = 48 million CPU milliseconds. Only usage above the included amounts adds to the base charge.

1. **Base subscription — $5.00**: Includes 10 million requests and 30 million CPU milliseconds.
2. **Extra requests — $0.60**: 2 million extra requests × $0.30 per million.
3. **Extra CPU — $0.36**: 18 million extra CPU milliseconds × $0.02 per million.

Compute total: $5.96. Assumes no other application uses the account's included allowance. Excludes taxes, logs, storage, and other services.

*The solid portion of each bar is included in the subscription; the striped portion is additional usage. Rates are those listed above, as of September 21, 2026.*

This assumes the account's included compute usage is available to this
application. It excludes taxes, logs, storage, and other services. The
4 ms figure is an assumption for the calculation, not a measured result.

Treat $5 as a starting charge, not a spending cap. Review usage and logging
volume as traffic grows; a small response can still be requested often.

## Follow a request through the logs

While `wrangler dev` is running, the terminal shows the example's log
entries. After deployment, stream live logs with:

```bash
npx wrangler tail
```

Send a request and compare its `X-Request-Id` response header with the
`requestId` in the `reading_api_response` log entry. For a staging deployment,
use `npx wrangler tail --env staging`.

The [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/)
view in the dashboard provides stored logs when observability is enabled.
Our `head_sampling_rate` of `1` selects every request; a value such as `0.1`
selects roughly 10%. Sampling is useful at higher volume, but also means a
particular request may have no stored log.

Logs have their own limits, retention, and pricing. This example writes an
application log in addition to the platform's invocation logging, so log
events and requests are not the same count.

A few symptoms point to different places:

| Symptom | First thing to inspect |
| --- | --- |
| `400` with our JSON error | The JSON syntax, text, language, and reading speed |
| `404` with our JSON error | The path: `/health` or `/api/reading-time` |
| `413` or `415` | Body size or the Content-Type header |
| No matching application log | The URL, target Worker, environment, and log sampling |
| A binding is undefined | Its name and the selected environment's configuration |
| `1102` resource-limit error | CPU and memory measurements for the failing invocation |
| Low CPU time, slow response | Network calls and backend latency |

For an application exception, match the response ID to the `reading_api_error` entry. Our handler records the exception name and returns a generic `500`. A bad `DEFAULT_WPM` is a configuration failure, not a client's `400`. If there is no application response, inspect the platform's invocation errors and resource measurements too.

## Know how to undo a deployment

A Worker version records code and configuration; a deployment selects
which version serves traffic. List recent versions with:

```bash
npx wrangler versions list
```

If a deployment breaks the API, choose a known-good deployed version and
[roll back](https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/):

```bash
npx wrangler rollback <VERSION_ID>
```

Replace the placeholder with the actual ID, and add `--env staging` when
that's the target. Repeat the HTTP checks against the deployed URL after
the rollback.

Rolling back code doesn't reverse database writes or restore deleted
storage resources. A deleted binding target or certain Durable Object
changes can also prevent a rollback. Once you add persistent data, migrations need their own
recovery plan and compatibility with the code you might roll back to.

## Where to take it next

You now have a small application whose behavior you can inspect end to end:
a request enters, input is checked, a response comes back, and a log records
what happened. The same handler can later use bindings to read data or call
another service.

For a useful extension, let an editor analyze several drafts in one request. Cap the batch size and total body size, then decide whether one invalid draft rejects the whole batch. Test the decision. Those are application rules; Hono organizes the routes, and Workers runs them.

The next part will add a frontend and cover serving a website on Workers,
including where Pages fits.

If you know someone moving their first API to Workers, share this guide
with them. What did you find hardest to understand in your first Worker:
the runtime, bindings, or deployment? [Send me your example](/en/contact),
or share it alongside the article on X or LinkedIn.
