- All posts
- Building on Cloudflare
- Cloudflare Workers: from a request to a running application
Cloudflare Workers: from a request to a running application
Understand the Workers runtime, build a bilingual reading-time API with Hono, and follow it through local development, deployment, bindings, costs, and debugging.
In the DNS guide, 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.
- Build and test the API.
- Deploy and connect a domain.
- Add configuration, secrets, and storage.
- Understand limits and costs.
- Find a failing request.
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 describes this execution model.
THE RUNTIME
Same code. Separate memory.
Imagine a visit counter stored in a global variable, starting at zero.
- Isolate A2Requests 1 and 2 arrive here. Its counter reaches 2.
- Isolate B1Request 3 reaches another isolate. Its counter starts at zero.
- A fresh isolate1Request 4 arrives after A is replaced. The new counter starts at zero.
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.
export default {
async fetch(request, env, ctx): Promise<Response> {
return new Response("The Worker answered.");
},
} satisfies ExportedHandler<CloudflareBindings>;
The handler receives three useful objects:
requestcontains the URL, method, headers, and body sent by the client.envexposes configured variables, secrets, and service bindings.ctxcontrols 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.
ONE HTTP REQUEST
From a URL to an answer
Follow a successful request to our reading-time API.
- ClientPOST /api/reading-timeThe client sends text, language, and an optional reading speed as JSON.
- CloudflareSelect the WorkerThe configured hostname determines which Worker receives the request.
- Your handlerValidate → countHono selects the route. The handler validates JSON and counts word-like segments.
- Back to the client200 · application/jsonReturn the status, headers, and JSON body. Invalid input takes an earlier exit with 400.
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
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 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:
npm create cloudflare@latest -- reading-api
Choose Hello World, Worker only, and TypeScript, and decline deployment for now. The Cloudflare project generator installs Wrangler. Then add Hono:
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:
{
"$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:
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.
TRY IT WITH YOUR OWN TEXT
How long would this take to read?
Count words in English or Arabic, then estimate reading time using a pace you choose.
An estimate based on the pace you choose. Your text stays in your browser. This uses the article’s analysis function; word boundaries can vary slightly between runtimes.
Inspect the API request and response
POST /api/reading-timeRequest body
Expected response body
How does the server check the request?
- POST /api/reading-time?No · return404 / 405Unknown paths return 404; other methods on this path return 405 with Allow: POST.Yes · continue
- Body within 64 KiB?No · return413The body-limit middleware rejects oversized requests before parsing JSON.Yes · continue
- JSON content type, valid JSON and fields?No · return415 / 400Wrong content type returns 415. Invalid JSON, text, language, or speed returns 400.Yes · continue
- Count words and estimate timeResponse200Return language, words, wordsPerMinute, seconds, and minutes. No database is needed.
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:
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 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:
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 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 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
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 explains the distinction.
In another terminal, send a paragraph:
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:
{
"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:
# 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
DEVELOP → VERIFY → PUBLISH
When does the code become public?
The first three steps run on your machine. The fourth publishes the Worker.
- 01 · Editsrc/index.ts + wrangler.jsoncYour handler and its configuration live in the project.
- 02 · Run locallywrangler dev --localTry real HTTP requests against a local Workers runtime.
- 03 · Check the bundlewrangler deploy --dry-runBuild the deployment bundle without publishing a version.
- 04 · Publishwrangler deployUpload the Worker and make the deployment serve traffic. Check its printed URL.
First check the types and the deployment bundle:
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:
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:
{
"routes": [
{
"pattern": "reading.example.com",
"custom_domain": true
}
]
}
Deploy again. Cloudflare manages the DNS record and certificate for the
Custom Domain.
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 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.
INSIDE THE HANDLER
What does env give your code?
The reading-time API uses the first entry. The others show how an application could grow.
c.envConfigured values and capabilities- c.env.SERVICE_NAMEA valueA non-secret label from vars: reading-api.
- c.env.PROVIDER_API_KEYA secretA credential your code can use when calling an external provider.
- c.env.DBA database bindingAn interface to one configured D1 database, with permission to use it.
- c.env.UPLOADSA bucket bindingAn interface to one configured R2 bucket. The files live in R2.
A binding
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 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:
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
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
let you deploy the same project as separate Workers. For example, add an
env property alongside the existing configuration:
{
"env": {
"staging": {
"routes": [],
"vars": {
"SERVICE_NAME": "reading-api-staging",
"DEFAULT_WPM": 200
}
}
}
}
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.
ONE PROJECT, TWO ENVIRONMENTS
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.
src/index.tsStaging
Workerreading-api-stagingc.env.DBD1 databasec.env.DB connects to a test database: reading-api-staging-db.Production
Workerreading-apic.env.DBD1 databasec.env.DB connects to a live database: reading-api-production-db.
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.
TRY A SLOWER BACKEND
Two clocks, one request
Keep the code's work fixed. Change only the time spent waiting for data.
- Validate3 ms CPUExecute the input checks.
- Wait for data200 ms waitingWaiting on the network adds elapsed time, without adding Worker CPU time.
- Format2 ms CPUBuild the response.
That distinction matters when choosing a plan. As of September 21, 2026, the Workers 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
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 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
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 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 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.
A HYPOTHETICAL MONTH · USD
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.
Estimate your compute cost
Paid Standard plan · USD · rates as of September 21, 2026. The $5 base charge still applies at zero usage. This does not estimate the Free plan.
Enter requests from 0 to 1,000 million and CPU time from 0 to 300,000 ms. The estimate is paused until both values are valid.
- Base subscriptionIncludes 10 million requests and 30 million CPU milliseconds.$5.00
- Extra requests2 million extra requests × $0.30 per million.$0.60
- Extra CPU18 million extra CPU milliseconds × $0.02 per million.$0.36
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:
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
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:
npx wrangler versions list
If a deployment breaks the API, choose a known-good deployed version and roll back:
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, or share it alongside the article on X or LinkedIn.