Tutorials Ultimate Web Development Series › Chapter 8

Your First Cloudflare Worker — A Backend in 5 Minutes

WebChapter 8 of the Ultimate Web Development Series30 minApril 20, 2026Beginner

In Chapter 7 you designed a notes API on paper. This chapter you ship it. A Cloudflare Worker is the fastest-to-deploy backend in the world — you'll have a live JSON API serving your traffic from 200+ cities in about five minutes.

By the end of this chapter you'll have installed the tooling, written a Worker that routes GET / POST / DELETE requests, tested it locally, added a secret, and deployed the final version to your own workers.dev subdomain.

What a Cloudflare Worker Actually Is

A Worker is a tiny JavaScript function that runs on Cloudflare's edge network. It's not a Node.js server, not a container, not a VM — it's a V8 isolate, the same sandbox mechanism Chrome uses to run tabs. That means:

Loading diagram…

Figure 1 — One wrangler deploy replicates your Worker to every edge location. Each user hits the copy nearest them — no load balancer, no region picking.

Install Wrangler (2 Minutes)

Wrangler is Cloudflare's CLI for Workers. It scaffolds projects, runs a local dev server, manages secrets, handles deploys. Install it once, use it forever.

# Prerequisite: Node.js 20+ (download from nodejs.org if you don't have it)
node --version    # should print v20.x.x or higher

# Install wrangler globally
npm install -g wrangler

# Or without the -g, use npx:
npx wrangler --version

# Log in to your Cloudflare account (free — sign up at cloudflare.com first)
wrangler login

wrangler login opens a browser tab. Approve it and you're authenticated. From now on, every Wrangler command runs against your account.

Scaffold Your First Worker

# Create a new Worker project
npm create cloudflare@latest my-notes-api

# Answer the prompts:
#   What type of application? → Hello World Worker
#   Do you want to use TypeScript? → No (we'll add types gradually)
#   Use git for version control? → Yes
#   Do you want to deploy your application? → No (we'll do it manually in a sec)

cd my-notes-api

You now have a directory that looks like:

my-notes-api/
├── package.json
├── wrangler.jsonc     # Worker config (name, routes, bindings)
├── src/
│   └── index.js       # your code
└── .gitignore

Four files. That's the whole project.

The Worker Handler — Four Lines That Matter

Open src/index.js:

export default {
  async fetch(request, env, ctx) {
    return new Response("Hello World!");
  },
};

That's a complete, functioning backend. Three things to understand:

The three arguments:

| Arg | What's in it | |---|---| | request | A standard Web Requestrequest.url, request.method, request.headers, await request.json() | | env | Environment variables, secrets, and bindings (D1, R2, KV, etc.) — wired up in wrangler.jsonc | | ctx | Context utilities — ctx.waitUntil(promise) lets work continue after the response ships |

No frameworks, no imports. You already know these APIs from browser fetch() in Ch 5 — a Worker's Request / Response are the same types, just on the receiving end.

Local Dev — wrangler dev

npx wrangler dev

A local server starts at http://localhost:8787. Open it:

# In another terminal
curl http://localhost:8787
# → Hello World!

Edit src/index.js, save, hit refresh — changes are instant. This is the development loop you'll live in for the rest of Part 2.

Routing — Handling Different Paths and Methods

A real API has multiple endpoints. Worker routing is just a switch (or if-chain) on request.url + request.method. No framework needed for Ch 8 scale.

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const { pathname } = url;
    const method = request.method;

    // GET /
    if (pathname === "/" && method === "GET") {
      return json({ hello: "world" });
    }

    // GET /api/notes
    if (pathname === "/api/notes" && method === "GET") {
      return json(notes);
    }

    // POST /api/notes
    if (pathname === "/api/notes" && method === "POST") {
      const body = await request.json();
      const note = { id: crypto.randomUUID(), ...body, createdAt: new Date().toISOString() };
      notes.push(note);
      return json(note, 201);
    }

    // GET /api/notes/:id
    const match = pathname.match(/^\/api\/notes\/([^\/]+)$/);
    if (match && method === "GET") {
      const note = notes.find(n => n.id === match[1]);
      if (!note) return json({ error: "not_found", message: "Note not found" }, 404);
      return json(note);
    }

    // DELETE /api/notes/:id
    if (match && method === "DELETE") {
      const i = notes.findIndex(n => n.id === match[1]);
      if (i === -1) return json({ error: "not_found" }, 404);
      notes.splice(i, 1);
      return new Response(null, { status: 204 });
    }

    return json({ error: "not_found", message: "Unknown endpoint" }, 404);
  },
};

// In-memory storage (we'll replace this with a real DB in Ch 9)
const notes = [];

// Little helper to save repetition
function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

Paste that in. Reload wrangler dev. You now have a functioning REST API.

Test with curl

# List (empty)
curl http://localhost:8787/api/notes
# → []

# Create
curl -X POST http://localhost:8787/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Groceries","body":"milk, eggs"}'
# → {"id":"abc123","title":"Groceries","body":"milk, eggs","createdAt":"2026-04-20T..."}

# List (now has the note)
curl http://localhost:8787/api/notes

# Get one
curl http://localhost:8787/api/notes/abc123

# Delete
curl -X DELETE http://localhost:8787/api/notes/abc123
# → (empty body, 204)

Every pattern from Ch 7 — resources, verbs, status codes, JSON bodies — expressed in ~40 lines of JavaScript.

The Worker Request Lifecycle

Loading diagram…

Figure 2 — The per-request flow inside a Worker. Everything you do between "invoke" and "return" has to finish before the response ships, unless you use ctx.waitUntil() to continue in the background.

Environment Variables and Secrets

Real APIs need configuration: which database to use, where the frontend lives, API keys for third-party services. Workers have two places for this.

Plain vars (non-secret, in config)

Open wrangler.jsonc:

{
  "name": "my-notes-api",
  "main": "src/index.js",
  "compatibility_date": "2026-01-01",

  "vars": {
    "WEB_ORIGIN": "https://myapp.com",
    "ENV": "production"
  }
}

Read them in code via the env argument:

async fetch(request, env) {
  console.log(env.WEB_ORIGIN);    // "https://myapp.com"
  console.log(env.ENV);           // "production"
}

Everyone who reads your repo can see these — they're committed. Fine for "which environment am I," not for secrets.

Secrets (passwords, API keys — never in git)

wrangler secret put STRIPE_SECRET_KEY
# Paste the key when prompted. It's encrypted + stored by Cloudflare;
# never appears in your repo or Worker logs.

Read it the same way — it's on env:

async fetch(request, env) {
  const stripeKey = env.STRIPE_SECRET_KEY;
  // use it to call Stripe's API...
}

List your secrets:

wrangler secret list

CORS — the one thing that bites every new backend author

Browsers enforce CORS (Cross-Origin Resource Sharing). If your frontend is at https://myapp.com and your API is at https://api.myapp.workers.dev, the browser will block the call unless your API sets the right headers. It looks like a server crash but is actually a browser-enforced security check.

Add this to every response:

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "https://myapp.com",      // your frontend origin
      "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

// And handle the browser's preflight OPTIONS request:
if (request.method === "OPTIONS") {
  return new Response(null, {
    headers: {
      "Access-Control-Allow-Origin": "https://myapp.com",
      "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

Use the specific origin (https://myapp.com), not *, once you go to production — otherwise any website on the internet can call your API in the user's name.

Deploy to the Edge

wrangler deploy

You'll see:

Uploaded my-notes-api (1.23 sec)
Deployed my-notes-api triggers (0.45 sec)
  https://my-notes-api.yourname.workers.dev
Current Version ID: 12345678-...

That URL is live, in every edge location, right now. Visit it:

curl https://my-notes-api.yourname.workers.dev/api/notes
# → []

You just shipped a backend. From project creation to global deploy, usually under five minutes.

Watch Logs and Errors

# Stream live logs from your deployed Worker
wrangler tail

# In another terminal, hit your API
curl https://my-notes-api.yourname.workers.dev/api/notes

# The tail terminal shows every request + any console.log output

console.log() in your Worker shows up in wrangler tail live. This is your primary debugging tool in production. Errors, uncaught exceptions, and request durations all appear here too.

The Full fetch Handler — Putting It Together

Here's a cleaned-up final version of the notes-API Worker with CORS, error handling, and a small router helper — roughly what you'll paste into Ch 9 to start wiring up D1:

// src/index.js

const WEB_ORIGIN = "*"; // swap for your real frontend origin in production
const notes = [];       // in-memory; Ch 9 replaces this with D1

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const { pathname } = url;
    const method = request.method;

    // CORS preflight
    if (method === "OPTIONS") return cors(new Response(null));

    try {
      // GET /api/notes
      if (pathname === "/api/notes" && method === "GET") {
        return cors(json(notes));
      }
      // POST /api/notes
      if (pathname === "/api/notes" && method === "POST") {
        const body = await request.json();
        if (!body.title) return cors(json({ error: "validation_failed", message: "title required", field: "title" }, 400));
        const note = {
          id: "nt_" + crypto.randomUUID().slice(0, 8),
          title: String(body.title).slice(0, 200),
          body: String(body.body ?? ""),
          createdAt: new Date().toISOString(),
        };
        notes.push(note);
        return cors(json(note, 201));
      }
      // GET /api/notes/:id  +  DELETE /api/notes/:id
      const m = pathname.match(/^\/api\/notes\/([^\/]+)$/);
      if (m) {
        const id = m[1];
        if (method === "GET") {
          const n = notes.find(n => n.id === id);
          return cors(n ? json(n) : json({ error: "not_found" }, 404));
        }
        if (method === "DELETE") {
          const i = notes.findIndex(n => n.id === id);
          if (i === -1) return cors(json({ error: "not_found" }, 404));
          notes.splice(i, 1);
          return cors(new Response(null, { status: 204 }));
        }
      }
      return cors(json({ error: "not_found", message: "Unknown endpoint" }, 404));
    } catch (err) {
      console.error("Unhandled error:", err);
      return cors(json({ error: "internal", message: "Something went wrong" }, 500));
    }
  },
};

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });
}

function cors(res) {
  const h = new Headers(res.headers);
  h.set("Access-Control-Allow-Origin", WEB_ORIGIN);
  h.set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
  h.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
  return new Response(res.body, { status: res.status, headers: h });
}

~70 lines. A production-shaped backend minus the database, which is next chapter's job.

Cron Triggers — Bonus Feature

Workers can also run on a schedule — useful for periodic jobs (syncing data, cleaning up, sending summary emails). Add to wrangler.jsonc:

{
  "triggers": {
    "crons": ["*/5 * * * *"]   // every 5 minutes
  }
}

Add a scheduled handler alongside fetch:

export default {
  async fetch(request, env, ctx) { /* HTTP requests */ },
  async scheduled(event, env, ctx) {
    // runs every 5 minutes
    console.log("Cron fired at", new Date().toISOString());
  },
};

The production Worker that powers the community features of simpleappshipper.com uses this pattern to sync social-media keyword monitoring every 5 minutes — Ch 14 will dissect it.

Exercise — Ship Your Paper API From Ch 7

Take the API you sketched at the end of Chapter 7 and build the skeleton now:

  1. npm create cloudflare@latest my-api — scaffold it.
  2. Write the route handlers with in-memory storage.
  3. Handle CORS for the frontend origin you'll use.
  4. Test every endpoint with curl.
  5. wrangler deploy. Share the workers.dev URL with a friend.

You'll have a real, global, stateless JSON backend to point fetch() at. That's more than most bootcamp graduates ship in their first year.

Next Steps

You now have a working Worker. But it forgets everything when the isolate restarts. The next chapter fixes that.

Next:

  1. Keep my-notes-api open. Chs 9-13 build directly on it — D1 in Ch 9, R2 in Ch 10, auth in Ch 11, OAuth in Ch 12, Stripe in Ch 13.
  2. Bookmark wrangler docs at developers.cloudflare.com/workers. It's the only Worker reference you'll need.
  3. Read the next chapter — Ch 9: SQL & Databases with D1, where we replace the notes = [] array with a real SQLite database living on Cloudflare, and every note survives a restart.
Ch 7: What's a Backend? HTTP, REST, JSONCh 9: SQL & Databases with D1

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access