Skip to the content

Module 1: How the web works

Module 2: The cloud, serverless and the edge

Module 3: Setting up: a terminal, an account and Wrangler

Module 4: Your first Worker

Module 5: Request, Response and URL

Module 6: Routing by hand

Module 7: Settings, secrets and the compatibility date

Module 8: Bindings and Workers KV

Module 9: D1: a SQL database

Module 10: Deploying, environments and custom domains

Module 11: R2: storing files

Module 12: Background work: waitUntil and Cron Triggers

Module 13: Queues: work that must happen, later

Module 14: Durable Objects: one place to coordinate

Module 15: Static assets and service bindings

Module 16: Caching and the Cache API

Module 17: Observing and testing Workers

Module 18: Inside the runtime: isolates, limits and speed

Module 19: Security and multi-Worker architecture

Module 20: Mastery challenge

Answer key

  1. 1. How the web works · A server is a program that answers · QuizA weather app on your phone asks a computer at a weather company for today’s forecast. Which is the client?B. The app on your phoneThe client is whoever asks. The app sends the request, so it is the client; the weather company’s computer answers, so it is the server. Clients are not only browsers: any program that sends requests is one.
  2. 1. How the web works · An address has parts · QuizIn https://shop.example.com/books/42?color=red, what is the path?B. /books/42The path starts at the first / after the host and stops at the ?. shop.example.com is the host, and color=red is the query, which is a separate part even though it follows the path.
  3. 1. How the web works · A response says how it went · QuizSomeone visits a short link that was never created. Which status should the server answer with?C. 404404 Not Found says the thing asked for does not exist, which is a fact about the request. 500 would claim the server broke, which it did not. 200 would say all is well while showing nothing useful.
  4. 1. How the web works · Headers are labels on the envelope · Put in orderPut a visit to https://example.com/ in order.1. You type the address and press Enter 2. The browser asks DNS for the address of example.com 3. The browser sends a GET / request to that address 4. The server answers with a status, headers and a body 5. The browser draws the pageThe name has to become a number (DNS) before the browser can send anything. Then one request goes out and exactly one response comes back, which the browser reads and draws.
  5. 1. How the web works · The project: a link shortener · QuizThe shortener answers 302. Which header tells the browser where to go?B. LocationA 3xx status means “look somewhere else”, and Location says where. Host is a request header naming the server being asked, not the destination.
  6. 2. The cloud, serverless and the edge · Serverless: you bring code, they run it when asked · QuizA serverless site gets no visitors at all for a week. What is it doing?B. Nothing: code only runs when a request arrivesServerless code runs per request. With no requests nothing runs, which is why it can be cheap for small sites. The code is still deployed and answers the moment someone visits.
  7. 2. The cloud, serverless and the edge · A Worker is your code on Cloudflare’s network · QuizYou deploy one Worker. A visitor in Nairobi opens your site. Where does your code run?C. In a Cloudflare location near NairobiDeploying copies the Worker to Cloudflare’s whole network, and each request runs where it lands: near the visitor. It does not run in the browser: the browser only sends the request and receives the response.
  8. 2. The cloud, serverless and the edge · A Worker comes with a toolbox · QuizWhich of these are good jobs for a Worker? Pick all that apply.A. Answering an API request with data from a database; B. Redirecting old addresses to new ones; D. Checking a sign-in token before passing a request onWorkers shine at short, request-shaped work near users: APIs, redirects, checks. A week of heavy computing on one machine is not request-shaped and would hit the limits on how long a Worker may compute.
  9. 2. The cloud, serverless and the edge · You write Workers in JavaScript or TypeScript · QuizWhen does TypeScript check the types in your code?B. Before the code runs: in the editor and when you check or buildTypes are checked ahead of time and then removed: what runs on Cloudflare is plain JavaScript. That is why types cost nothing at run time, and also why they cannot catch a bad value arriving from outside (you check those in code).
  10. 3. Setting up: a terminal, an account and Wrangler · Node.js and npm run the tools · QuizIs Node.js what runs your Worker on Cloudflare?B. No: Node.js runs the tools on your computer; Cloudflare runs Workers in its own runtimeWorkers run in Cloudflare’s runtime, called workerd, built on the V8 engine. Node.js uses V8 too, but it is a different program with different built-ins. You need Node on your machine only for npm, Wrangler and tests.
  11. 3. Setting up: a terminal, an account and Wrangler · One command makes a project · Fill the blanksMake a new Worker project called link-shortener, then move into it.1: create, 2: cdnpm create cloudflare downloads and runs C3, the project maker. cd moves the terminal into the new folder, which every following command expects.
  12. 3. Setting up: a terminal, an account and Wrangler · What is in the new folder · QuizWhich file says what the Worker is called and where its code starts?B. wrangler.jsoncwrangler.jsonc describes the Worker to Cloudflare: its name, its entry file (main) and everything it is connected to. package.json is about npm packages and scripts, and src/index.ts is the code that main points to.
  13. 3. Setting up: a terminal, an account and Wrangler · Wrangler is Cloudflare’s command-line tool · Fill the blanksLog in, then check which account Wrangler will use.1: wrangler, 2: login, 3: whoamiwrangler login stores a sign-in on your machine after you approve it in the browser. wrangler whoami prints the account, which is worth checking when you belong to more than one.
  14. 3. Setting up: a terminal, an account and Wrangler · Reading an error message · QuizYou open a new terminal and run npx wrangler dev. It stops with Missing entry-point to Worker script. What is the most likely fix?B. Run cd link-shortener firstA new terminal starts in your home folder, not in the project. Wrangler looks for its config in the folder you run it from (and the folders above it); without one, it does not know where your code starts. Logging in would not help: the error is about a missing file, not a missing account.
  15. 4. Your first Worker · Make it redirect · QuizYou run curl -i http://localhost:8787/anything against this Worker. What comes back?B. A 302 with Location: https://example.com/soupThe Worker ignores the path and redirects every request. curl -i shows the status and headers, and does not follow redirects unless you add -L. A browser would follow it and show the page.
  16. 4. Your first Worker · wrangler deploy puts it on the internet · Put in orderPut what happens during wrangler deploy in order.1. Wrangler reads wrangler.jsonc 2. It bundles src/index.ts and its packages into one file 3. It uploads the bundle and settings to your account 4. Cloudflare starts sending requests to the new code 5. Visitors to the workers.dev address get the new responseConfig first, because it says where the code starts (main). The bundle is uploaded together with the settings, and only then does Cloudflare switch traffic to it.
  17. 4. Your first Worker · Request, env and ctx · Fill the blanksComplete the smallest typed Worker.1: default, 2: fetch, 3: Response, 4: satisfiesThe runtime looks for the default export and calls its fetch method per request. The method must return a Response. satisfies ExportedHandler<Env> checks the shape and types the arguments.
  18. 4. Your first Worker · When your code throws · Find the bugThe browser shows an error instead of the redirect. Which line is wrong?Line 3: return Response.redirect('https://example.com/soup', 302)The response is built and then thrown away: without return, fetch returns nothing, and the runtime has no Response to send. TypeScript also flags it, because ExportedHandler says fetch must return a Response.
  19. 5. Request, Response and URL · URL takes an address apart · QuizWhat does this print?A. /a/b/ 2The path keeps its leading and trailing slashes, and stops before ?. When a query name repeats, get returns the first value; getAll('n') would give both. console.log prints its values separated by a space.
  20. 5. Request, Response and URL · Try it: answer from the path and the query · Code challengeFinish worker.fetch. For the path /hello, answer Hello, <name> with the name query parameter, or Hello, world when there is none. Any other path answers 404.One solution:
    Solution
    const worker = {  async fetch(request: Request): Promise<Response> {    const url = new URL(request.url)    if (url.pathname === '/hello') {      const name = url.searchParams.get('name') ?? 'world'      return new Response(`Hello, ${name}`)    }    return new Response('Not found', { status: 404 })  }}
    pathname stops before the ?, so /hello?name=Ada still has the path "/hello", slash included. searchParams.get returns null for a missing parameter, and ?? swaps that null for "world". A common slip is comparing with "hello", which never matches.
  21. 5. Request, Response and URL · A body can be read once · QuizWhat happens when a Worker runs these two lines on one request?B. The second line throws, because the body was already usedReading a body consumes its stream. Read it once and reuse the result (JSON.parse(text)), or call request.clone() before the first read if two pieces of code each need the original.
  22. 5. Request, Response and URL · Building a response · Fill the blanksAnswer with JSON and a 201 Created status.1: json, 2: status, 3: 201Response.json turns the object into JSON text and sets Content-Type: application/json. The options object sets the status; without it you get 200.
  23. 5. Request, Response and URL · Try it: read JSON, answer JSON · Code challengeFinish worker.fetch for a POST whose body is { "code": "…" }. Answer 201 with the JSON { "shortUrl": "https://go.example.com/<code>" }. When the body is not valid JSON, request.json() throws: catch it and answer 400.One solution:
    Solution
    type NewLink = { code: string }
    const worker = {  async fetch(request: Request): Promise<Response> {    let data: NewLink    try {      data = (await request.json()) as NewLink    } catch {      return Response.json({ error: 'bad json' }, { status: 400 })    }    const shortUrl = `https://go.example.com/${data.code}`    return Response.json({ shortUrl }, { status: 201 })  }}
    request.json() reads the body stream and parses it in one go; text that is not JSON makes it reject, and try/catch around the await turns that into a 400 instead of a crash. Response.json sets Content-Type: application/json for you, and its second argument carries the status. Without { status: 201 } the answer would be a plain 200.
  24. 5. Request, Response and URL · fetch() asks another server · QuizA fetch gets back a 404. What is response.ok, and does fetch throw?B. false, and fetch does not throwfetch only rejects when no response arrives at all, such as a network failure. A 404 or 500 is still a response: check response.ok (true for 200 to 299) or response.status yourself.
  25. 5. Request, Response and URL · What Cloudflare adds to the request · Find the bugThis Worker should greet visitors at /hello and give a 404 elsewhere, but every path gets a 404. Which line?Line 4: if (url.pathname === '/hello') {pathname always starts with a slash: a visit to /hello gives "/hello", which never equals "hello". Comparing paths is the start of routing, the next module’s subject.
  26. 6. Routing by hand · Order matters · QuizWith the routes checked in this order, what does GET /health return?B. Whatever redirectOrNotFound('health') returns, probably a 404/health gives the code "health", which is truthy, so the first if answers and the second never runs. A handler returns one response; the first return ends it. Put /health above the catch-all.
  27. 6. Routing by hand · Paths arrive encoded · Fill the blanksTurn the path /caf%C3%A9 into the code café.1: decodeURIComponent, 2: sliceslice(1) removes the leading slash, and decodeURIComponent reverses the percent-encoding. The order does not matter here, because the leading slash is a plain character. Note that decoding turns %2F into a real /: when you split a path into segments, split first, then decode each one.
  28. 6. Routing by hand · The wrong method is a 405 · QuizA client sends PUT /health. /health only answers GET. Which status is right?D. 405The path exists, the method is wrong: that is exactly 405. 400 means the request itself is malformed (bad JSON, a missing field), which is a different problem.
  29. 6. Routing by hand · Try it: a small router · Code challengeFinish the router. /api/links answers 405 with an Allow: POST header for any method but POST (creating links comes in the KV module). Then a GET for a code in LINKS redirects to its link with 302. Everything else stays 404.One solution:
    Solution
    const LINKS: Record<string, string> = {  soup: 'https://example.com/recipes/tomato-soup'}
    const worker = {  async fetch(request: Request): Promise<Response> {    const { pathname } = new URL(request.url)    if (pathname === '/health') return new Response('ok')    if (pathname === '/api/links' && request.method !== 'POST') {      return new Response('Method not allowed', {        status: 405,        headers: { Allow: 'POST' }      })    }    const target = LINKS[pathname.slice(1)]    if (request.method === 'GET' && target) {      return Response.redirect(target, 302)    }    return new Response('Not found', { status: 404 })  }}
    The /api/links check sits above the catch-all, so the path is never mistaken for a short code. The path exists, so a wrong method is 405, not 404, and Allow tells the client what would work. Response.redirect sets both the status and the Location header. Header names are case-insensitive, which is why the tests can read location and allow in lowercase.
  30. 6. Routing by hand · Catch errors once, at the top · Find the bugWhen route rejects, visitors still get Cloudflare’s error page instead of the JSON. Which line?Line 4: return await route(request, env)return route(…) hands the promise back without waiting for it, so the try is already over when it fails. return await waits inside the try, and the rejection lands in catch.
  31. 6. Routing by hand · When to use a router library · Put in orderOrder the checks in a hand-written router, from first to last.1. Parse the URL 2. Exact routes such as /health 3. Routes with parameters such as /api/links/:code/stats 4. The catch-all /:code 5. The 404 fallbackYou need the parsed URL before any check. Then go from most specific to least: exact paths, then patterns, then the catch-all, then the 404 for whatever is left.
  32. 7. Settings, secrets and the compatibility date · Vars live in wrangler.jsonc · QuizA var is set to "SIGNUPS_OPEN": "false". What does if (env.SIGNUPS_OPEN) { … } do?B. Runs the block, because "false" is a non-empty stringWritten in quotes, the value is the text "false", and any non-empty string counts as true in an if. Compare explicitly: env.SIGNUPS_OPEN === 'true'.
  33. 7. Settings, secrets and the compatibility date · Secrets never go in the config · Fill the blanksStore the token the admin API checks.1: secret, 2: putwrangler secret put NAME prompts for the value and uploads it encrypted. Afterwards no one, including you, can read it back from Cloudflare: you can only replace or delete it.
  34. 7. Settings, secrets and the compatibility date · Using a secret to guard a route · Find the bugThis works, but one line is a security problem. Which?Line 3: const token = env.ADMIN_TOKENA token written in code is in git, in every copy of the project and in the uploaded bundle. Read it from env, where the secret is injected. (Comparing secrets with !== has a subtle weakness too; the security module shows a safer comparison.)
  35. 7. Settings, secrets and the compatibility date · Try it: check the token · Code challengeWrite isAdmin(request, env): true only when the Authorization header is exactly Bearer followed by env.ADMIN_TOKEN, and always false when the secret is missing or empty.One solution:
    Solution
    interface Env {  ADMIN_TOKEN?: string}
    function isAdmin(request: Request, env: Env): boolean {  if (!env.ADMIN_TOKEN) return false  const sent = request.headers.get('authorization')  return sent === `Bearer ${env.ADMIN_TOKEN}`}
    The header must match the whole string Bearer test-token. The trap is the missing secret: forget wrangler secret put and env.ADMIN_TOKEN is undefined, so the template string becomes "Bearer undefined", a password anyone could guess. Refuse first when the secret is empty. (The security module swaps === for a constant-time comparison.)
  36. 7. Settings, secrets and the compatibility date · wrangler types writes the Env type · QuizYou add a var to the config and deploy, but forget to run wrangler types. What happens?B. The var works at run time; only TypeScript does not know about it yetTypes only describe; the config is what the runtime reads. Stale types make the editor complain about a var that exists (or, worse, stay quiet about one you removed). wrangler types --check can fail a build when the file is out of date.
  37. 7. Settings, secrets and the compatibility date · The compatibility date pins the runtime’s behaviour · QuizA Worker was deployed two years ago and never touched. Since then, the runtime changed a default that would break it. What happens?B. Nothing: it keeps the old behaviour until its date is moved forwardThat is what the date is for: breaking changes are opt-in. The cost of an old date is missing newer behaviour and some newer APIs, so move it forward deliberately, with tests.
  38. 8. Bindings and Workers KV · Create a namespace and bind it · Fill the blanksBind a namespace as env.LINKS.1: kv_namespaces, 2: bindingkv_namespaces lists the namespaces this Worker may use. binding is the name your code sees on env; id says which namespace it is. The id is not a secret, but it belongs to your account, so keep the real one out of examples.
  39. 8. Bindings and Workers KV · Try it: read and delete · Code challengeFinish worker.fetch. A GET /<code> looks the code up in env.LINKS and redirects (302) to the stored link, or answers 404 when get returns null. A DELETE /<code> deletes the key and answers 204 with no body.One solution:
    Solution
    // The part of KVNamespace this Worker usesinterface KV {  get(key: string): Promise<string | null>  delete(key: string): Promise<void>}interface Env {  LINKS: KV}
    const worker = {  async fetch(request: Request, env: Env): Promise<Response> {    const code = new URL(request.url).pathname.slice(1)    if (request.method === 'DELETE') {      await env.LINKS.delete(code)      return new Response(null, { status: 204 })    }    const target = await env.LINKS.get(code)    if (target === null) {      return new Response('Not found', { status: 404 })    }    return Response.redirect(target, 302)  }}
    get answers null for a missing key, never undefined or an error, so compare with null. Both calls return promises: without await, target would be a promise (always truthy) and the delete might not finish before the response. 204 No Content is the usual answer to a delete that worked, and its body must be null.
  40. 8. Bindings and Workers KV · JSON values and expiry · QuizA link is saved with expirationTtl: 3600. Someone visits it two hours later. What does get return?B. null3600 seconds is one hour. After that the key is gone, as if it was deleted, and get returns null. Your code cannot tell an expired key from one that never existed, which is usually what you want.
  41. 8. Bindings and Workers KV · KV is eventually consistent · QuizYou change where soup points. Seconds later, a friend on another continent opens the short link. Where do they land?B. Maybe the old address, for up to a minute or soTheir nearest location may still hold the old value. That is fine for a link shortener, where a short delay harms no one. For a value that must be exact the moment it changes, such as stock in a shop or a counter, use D1 or a Durable Object.
  42. 8. Bindings and Workers KV · Creating links through the API · Find the bugSometimes a link reported as created is not there afterwards. Which line?Line 6: await env.LINKS.put(code, url)Without await, the response is sent before the write finishes, and once a request is over the runtime may cancel work still in flight. Always await storage calls (or hand them to ctx.waitUntil, in the background work module).
  43. 8. Bindings and Workers KV · Try it: create a link · Code challengeFinish createLink(link, env). If link.code is already in env.LINKS, answer 409 with { "error": "taken" }. Otherwise put the link with an expirationTtl of 30 days (in seconds), and answer 201 with { code, url }.One solution:
    Solution
    // The part of KVNamespace this code usesinterface KV {  get(key: string): Promise<string | null>  put(    key: string,    value: string,    options?: { expirationTtl?: number }  ): Promise<void>}type NewLink = { code: string; url: string }
    async function createLink(link: NewLink, env: { LINKS: KV }) {  const { code, url } = link  if ((await env.LINKS.get(code)) !== null) {    return Response.json({ error: 'taken' }, { status: 409 })  }  await env.LINKS.put(code, url, { expirationTtl: 60 * 60 * 24 * 30 })  return Response.json({ code, url }, { status: 201 })}
    get returns null only when the key is free, so anything else means taken. expirationTtl counts seconds: 60 × 60 × 24 × 30 is 2,592,000. Await the put before answering, or the 201 can go out before the write is done. As the callout said, check-then-write can still race between two requests; this is fine for a shortener, not for anything that must be unique.
  44. 9. D1: a SQL database · D1 is SQLite, run by Cloudflare · QuizThe shortener wants “clicks per day for each link, for the last 30 days”. Where should that live?B. D1: a table of daily countsCounts change on every click and need filtering and sorting by day: that is SQL’s job. KV would allow only about one write per second per key and cannot query by date. A variable lives in one isolate only and is lost when it restarts.
  45. 9. D1: a SQL database · Create a database and bind it · Fill the blanksBind the database as env.DB.1: d1_databases, 2: bindingd1_databases lists the databases; binding is the name on env; database_name is what you call it in commands; migrations_dir is where the migration files live (migrations is the default).
  46. 9. D1: a SQL database · Migrations change the database step by step · Put in orderPut adding a table in order.1. npx wrangler d1 migrations create shortener daily_clicks 2. Write the CREATE TABLE in the new file 3. npx wrangler d1 migrations apply shortener --local 4. Try it with wrangler dev 5. npx wrangler d1 migrations apply shortener --remotemigrations create makes the numbered empty file. Apply to the local database first and test, and only then to the real one with --remote. A migration that has been applied remotely should never be edited: write a new one instead.
  47. 9. D1: a SQL database · Never build SQL from strings · Find the bugWhich line lets a visitor change the query?Line 3: const stmt = env.DB.prepare('SELECT * FROM daily_clicks WHERE code = ?').bind(code)Line 3 puts raw input inside the SQL text. Use a ? placeholder and bind(code). Line 1 is safe enough: Number turns anything that is not a number into NaN, which is never SQL.
  48. 9. D1: a SQL database · Count clicks in one statement · QuizTwo clicks on soup arrive at the same moment. The code reads clicks, adds 1 in JavaScript and writes it back. Today’s count was 41. What can it end at?B. 42 or 43If both reads happen before either write, both compute 42 and one click is lost. The single UPDATE … clicks + 1 cannot lose one, because the database applies each statement on its own.
  49. 10. Deploying, environments and custom domains · Rollback undoes code, not data · QuizVersion 8 added a column with a migration and started writing to it. You roll back to version 7. What is true?B. The column stays; version 7 runs against the database as it is nowRollback swaps code only. That is why schema changes should be additive: version 7 never reads the new column, so it keeps working. A migration that dropped a column version 7 needs would make the rollback fail at run time.
  50. 10. Deploying, environments and custom domains · Environments: a staging copy from the same file · QuizIf the staging block had only its vars, what would env.LINKS be in the staging Worker?B. undefined: bindings are not inheritedKeys like main and compatibility_date flow down to environments; bindings and vars do not, so staging cannot write to production by accident. Give each environment its own resources.
  51. 10. Deploying, environments and custom domains · Routes put a Worker in front of part of a site · QuizA bakery has a website at bakery.example on another server, and wants bakery.example/l/* to be short links. Which fits?B. A route bakery.example/l/* on the bakery.example zoneA custom domain would make the Worker answer the whole hostname, replacing the bakery’s site. A route runs the Worker only for /l/*; every other path still reaches the existing server.
  52. 10. Deploying, environments and custom domains · Gradual deployments: a little traffic first · Put in orderPut a careful release in order.1. Apply any additive migration with --remote 2. wrangler versions upload 3. Try the preview URL 4. Deploy the new version at 10% 5. Watch errors, then move to 100%The schema change comes first, so both versions can run on it. Uploading does not change traffic; the preview URL lets you check it; then traffic moves in steps while you watch.
  53. 11. R2: storing files · Create a bucket and bind it · Fill the blanksBind the bucket as env.FILES.1: r2_buckets, 2: FILES, 3: bucket_nameR2 buckets are bound by name (bucket_name), not by an id. binding is what the code sees: env.FILES, typed as R2Bucket after wrangler types.
  54. 11. R2: storing files · Serving a file · QuizAn image was uploaded without httpMetadata, and the Worker serves it as above. What will a browser most likely do when opening the link?B. Not know it is an image, and download it or show nothingWithout Content-Type, the browser has to guess what the bytes are. Save the type when you upload, or set the header when you serve. R2 stores bytes; it does not look inside them to guess.
  55. 11. R2: storing files · Stream, do not buffer · QuizWhy does streaming a 1 GB file from R2 work when loading it with arrayBuffer() does not?B. A stream passes small pieces through, so the whole file is never in memory at onceA Worker’s memory is far smaller than 1 GB. With a stream, only the pieces in transit are in memory. The ! in the left code also hides a real case: get can return null.
  56. 11. R2: storing files · head, list and delete · Find the bugDeleting a link should also delete its QR image, but images pile up. Which line?Line 3: await env.FILES.delete(qr/${code})The images were stored under qr/<code>, so deleting the key soup deletes nothing (and does not fail: deleting a missing key is not an error). Keep key naming in one helper function so writes and deletes cannot drift apart.
  57. 12. Background work: waitUntil and Cron Triggers · ctx.waitUntil keeps the Worker alive a little longer · Find the bugSome clicks are never counted. Which line?Line 5: ctx.waitUntil(countClick(env, code))A promise nobody awaits is called floating. Once the response is sent, the runtime may cancel it halfway. Wrap it in ctx.waitUntil so the runtime knows to wait for it.
  58. 12. Background work: waitUntil and Cron Triggers · Try it: count without waiting · Code challengeThe redirect should go out at once, and the click be counted in the background. Hand countClick(code) to ctx.waitUntil, without awaiting it.One solution:
    Solution
    interface Ctx {  waitUntil(promise: Promise<unknown>): void}const LINKS: Record<string, string> = {  soup: 'https://example.com/soup'}const clicks = new Map<string, number>()
    async function countClick(code: string) {  await new Promise((done) => setTimeout(done, 20))   // a slow write  clicks.set(code, (clicks.get(code) ?? 0) + 1)}
    const worker = {  async fetch(request: Request, env: unknown, ctx: Ctx) {    const code = new URL(request.url).pathname.slice(1)    const target = LINKS[code]    if (!target) return new Response('Not found', { status: 404 })    ctx.waitUntil(countClick(code))    return Response.redirect(target, 302)  }}
    countClick(code) starts the work and returns its promise; waitUntil takes that promise and tells the runtime to keep the request alive until it settles. Writing await countClick(code) would also count the click, but the visitor would wait for the write: the first test catches that. Calling countClick(code) alone, a floating promise, would not delay the redirect either, but the second test catches it: in production that work can be cut off.
  59. 12. Background work: waitUntil and Cron Triggers · What waitUntil does not promise · QuizcountClick inside ctx.waitUntil throws because D1 is briefly unreachable. What happens?C. The error is logged and that click is not countedThe visitor was already redirected, and waitUntil has no retry. For a click count, losing one now and then is acceptable. For work that must happen, use a queue (next module), which retries.
  60. 12. Background work: waitUntil and Cron Triggers · Cron: code on a timetable · QuizWhen does 15 4 * * * run?B. At 04:15 UTC every dayFields go minute first, then hour: minute 15, hour 4, every day of every month. Times are UTC, so in Lima (UTC−5) it runs at 23:15 the evening before.
  61. 12. Background work: waitUntil and Cron Triggers · Several schedules, one handler · Fill the blanksRun the clean-up nightly and a summary every Monday.1: scheduled, 2: cron, 3: MONThe handler is called scheduled, and controller.cron is the exact expression from the config, character for character, so compare with the same text. In Cloudflare’s cron, 1 is Sunday and Monday is 2: the name MON avoids that mix-up.
  62. 12. Background work: waitUntil and Cron Triggers · Which background tool for which job · QuizAfter a link is created, the owner must get a confirmation email even if the email service is down for ten minutes. Which tool?C. A QueuewaitUntil gives up after one failure. A cron could poll for unsent emails, but it needs its own bookkeeping. A queue stores the message and retries delivery until it succeeds (or reaches its retry limit).
  63. 13. Queues: work that must happen, later · Create the queue and bind both sides · Fill the blanksLet this Worker send to clicks as env.CLICKS, and consume it in batches of up to 100.1: producers, 2: consumers, 3: max_batch_sizeproducers gives a Worker a binding to send with. consumers makes this Worker’s queue handler receive the messages. A batch is delivered when it reaches max_batch_size messages or after max_batch_timeout seconds, whichever comes first. Producer and consumer may be the same Worker or different ones.
  64. 13. Queues: work that must happen, later · Sending messages · QuizA message is handled seconds or minutes after it is sent. What belongs in it?A. The facts as they were when it happened: the code, the day, the countryMessages are copied into storage, so they must be plain data. A Request or a connection cannot be copied and would mean nothing later anyway. Record the facts you need, at the time they happened.
  65. 13. Queues: work that must happen, later · Success, failure and retries · QuizA consumer handles a batch of 10 without calling ack(), and throws on message 3. What is delivered again?B. All 10, messages 1 and 2 includedA throw retries every message not explicitly acked. Messages 1 and 2 were handled but never acknowledged, so they come back. Ack each one when it is done if the work has side effects that must not repeat.
  66. 13. Queues: work that must happen, later · Try it: ack or retry each message · Code challengeFinish handleBatch. For each message, await send(message.body), then ack() it. If send throws, call retry({ delaySeconds: 60 }) for that message and carry on with the next.One solution:
    Solution
    interface Message<Body> {  body: Body  ack(): void  retry(options?: { delaySeconds?: number }): void}type Email = { to: string }
    async function handleBatch(  messages: Message<Email>[],  send: (email: Email) => Promise<void>) {  for (const message of messages) {    try {      await send(message.body)      message.ack()    } catch {      message.retry({ delaySeconds: 60 })    }  }}
    The try wraps one message, so a failure is caught for that message alone and the loop moves on. ack() goes after the await, only once the email is really sent. Without the try, the first failure would throw out of the handler, and every message not yet acked would come back, the ones already sent included.
  67. 13. Queues: work that must happen, later · Giving up: the dead letter queue · Put in orderPut the life of a message that keeps failing in order.1. The producer calls send() 2. The queue delivers it in a batch 3. The consumer throws 4. It is retried until max_retries is reached 5. It moves to the dead letter queueEach failure sends it back to the queue for another try. Only when the retries run out does it go to the dead letter queue (or vanish, if there is none).
  68. 13. Queues: work that must happen, later · Bigger batches, or quicker ones · QuizWith max_batch_size: 100 and max_batch_timeout: 5, a quiet night brings 3 clicks a minute. When does the consumer run?B. About every 5 seconds a message is waiting, with whatever has arrivedA batch goes out when it is full or when the timeout passes, whichever is first. On a quiet night the timeout wins, so batches are small but nothing waits more than about 5 seconds.
  69. 14. Durable Objects: one place to coordinate · Many copies cannot agree on their own · QuizWhy not keep the count in a variable at the top of the Worker’s file?B. Each isolate, in each location, has its own copy, and any of them can be restarted at any timeA top-level variable lives in one isolate. Visitors in different cities, or even two requests in the same city, can reach different isolates, each with its own count, and an isolate can be replaced at any moment, losing it.
  70. 14. Durable Objects: one place to coordinate · Binding and creating the class · Fill the blanksBind the class as env.LIMITED and create it with SQLite storage.1: class_name, 2: migrations, 3: new_sqlite_classesThe binding names the exported class. Creating, renaming or deleting Durable Object classes goes through migrations, each with a unique tag, applied at deploy. new_sqlite_classes gives the class SQLite-backed storage, which is what Cloudflare recommends for new classes.
  71. 14. Durable Objects: one place to coordinate · Calling an object by name · QuizTwo visitors, one in Paris and one in Sydney, open /vip at the same instant. Both Workers call getByName('link:vip'). What do they reach?B. The same single object; their calls run one after the otherA name maps to exactly one object. It lives in one place (usually near where it was first used), so the visitor further away pays a longer trip. That is the price of a single source of truth.
  72. 14. Durable Objects: one place to coordinate · One at a time, until you await something else · QuizOne place is left. Two calls to admit arrive together. What can happen?B. Both can be admitted: the fetch lets the second call read used before the first writesSingle-threaded means no two calls run at the same instant, not that each runs start to finish alone. During the outside fetch, the second call reads the old value. Make the decision and the write with storage only, then do outside work.
  73. 14. Durable Objects: one place to coordinate · When to use one, and how to keep it fast · QuizA team puts every click from every link through one object named global. What will they hit first?B. Throughput: one object handles calls one at a time, so it becomes a bottleneckObjects scale by having many of them, not by making one bigger. One per link spreads the work; the totals across links belong in D1, filled by a queue consumer.
  74. 15. Static assets and service bindings · Who answers: a file or the Worker · QuizWith the config above and public/favicon.ico present, which requests run the Worker’s code?B. GET /soupThe two files exist, so the asset layer answers them with no code running. /soup matches no file, so the request goes to the Worker, which looks the code up in KV.
  75. 15. Static assets and service bindings · Service bindings: Worker to Worker, privately · Fill the blanksGive the public Worker a binding API to the Worker named links-api.1: services, 2: API, 3: serviceservices lists the Workers this one may call. binding is the name on env; service is the other Worker’s name in your account.
  76. 15. Static assets and service bindings · RPC: call methods instead of URLs · QuizWhy use a service binding instead of fetch('https://links-api.<subdomain>.workers.dev/…')?B. The binding skips the public internet, and the API needs no public address anyone could callA public URL works, but it goes out and back through the internet, and it means anyone can call the API. With a binding the API can be completely unreachable from outside: the smallest attack surface is the one that does not exist.
  77. 15. Static assets and service bindings · Several Workers on your computer · Find the bugThe caller gets an error that resolve does not exist. Which line of its config is wrong?Line 3: "service": "links-api", "entrypoint": "Links"Without entrypoint, the binding points at the default export, which has only fetch. Naming the Links entrypoint connects it to the class that has resolve.
  78. 16. Caching and the Cache API · Cache-Control says who may keep a copy, and for how long · QuizA Worker returns the owner’s list of links with their click counts. Which header fits?B. Cache-Control: private, no-storeThis is one person’s data. A shared cache could hand it to the next visitor. private keeps shared caches out, and no-store stops even the browser keeping it, which suits data that changes and should not linger.
  79. 16. Caching and the Cache API · A cached redirect is a click you never see · QuizA link was answered with 301 last week. Today you point it somewhere else. Where does a returning visitor land?B. Very likely the old address, from their browser’s cache, without reaching the WorkerA permanent redirect is remembered by the browser. Use 301 only when the destination truly never changes; a shortener whose links can be edited should answer 302 or 307.
  80. 16. Caching and the Cache API · What the Cache API will and will not do · QuizYou load /stats twice from Lima: the second load is a cache hit. A moment later, a friend in Madrid loads /stats for the first time. Hit or miss?B. A miss: Madrid’s location has its own cache, and nothing has filled it yetcaches.default belongs to the location that ran the code, and its entries are never copied elsewhere. Each location builds the answer once, on its own first miss, then serves its copy. This works on workers.dev and on your own domains alike (only the dashboard’s Playground previews ignore it), and wrangler dev simulates it locally.
  81. 16. Caching and the Cache API · Choose the cache key on purpose · Find the bugPer-link stats live at /api/links/:code/stats?days=7 or ?days=30. Visitors asking for 30 days sometimes get 7. Which line?Line 3: const key = new Request(url.origin{url.pathname}?days=${days})Dropping the whole query merged two different answers into one key. Normalize by keeping exactly the parts that change the answer (days) and dropping only the ones that do not (tracking tags).
  82. 16. Caching and the Cache API · Try it: normalize a cache key · Code challengeWrite cacheKey(request). It returns a new GET Request for the same origin and path, keeping the days query parameter when there is one and dropping every other (such as utm_source).One solution:
    Solution
    function cacheKey(request: Request): Request {  const url = new URL(request.url)  const key = new URL(url.origin + url.pathname)  const days = url.searchParams.get('days')  if (days !== null) key.searchParams.set('days', days)  return new Request(key, { method: 'GET' })}
    Start from a URL with no query at all, then copy back only what changes the answer. That is an allowlist again: a new tracking tag tomorrow is dropped without any change to the code. The opposite, deleting the tags you know about, lets every new one split the cache. The Cache API only stores GET requests, so the key is one.
  83. 16. Caching and the Cache API · What not to cache · QuizWhich of these is safe to cache in a shared cache for a minute? Pick all that apply.A. The public page about the service; B. The QR code image of a linkPublic, identical answers are safe. The owner’s settings belong to one person, and a POST changes data: caching its response would make the next creation return the previous result.
  84. 17. Observing and testing Workers · What never goes in a log · QuizWhich of these is fine to log on a failed admin request?A. { event: 'auth-failed', path, country }The path and the country help you understand what happened without exposing anything. The header could be a real token typed slightly wrong, and a body can hold anything a person sent you.
  85. 17. Observing and testing Workers · wrangler tail: watch it live · Fill the blanksWatch only the failing invocations of the deployed Worker.1: tail, 2: status, 3: errortail connects to the deployed Worker (named by the config, or by the argument). --status error keeps only invocations that threw or failed, which is what you want while a new version takes traffic.
  86. 17. Observing and testing Workers · Metrics: the shape of the traffic · QuizAfter a release, wall time doubles but CPU time stays the same. What is the most likely cause?B. Something your code waits on (a database, an API) got slowerCPU time counts only computing; wall time includes waiting. Same CPU with more wall time points to waiting: a slower query, a new subrequest, a far-away database. Look at what the release added that awaits.
  87. 17. Observing and testing Workers · Calling the Worker in a test · Fill the blanksCheck that an unknown code gives a 404.1: it, 2: fetch, 3: expect, 4: toBeit names a test, exports.default.fetch sends a request to the Worker, and expect(…).toBe(…) is the assertion. Nothing in the test stored nope, so the Worker finds no link for it.
  88. 17. Observing and testing Workers · Testing crons and the database · QuizA test calls fetch on the Worker and immediately checks D1 for the click. It fails, though the click is counted in production. Why?B. The click is written in ctx.waitUntil, which may not have finished when the test checksThe response returns before background work ends. Wait for it: call the handler with your own context and waitOnExecutionContext(ctx), or retry the check with vi.waitFor.
  89. 17. Observing and testing Workers · Fake only the outside world · Find the bugThis test is meant to stay offline, but it really calls the link’s website every run. Which line?Line 3: vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 }))vi.spyOn on its own only watches: every call still goes to the real fetch. Give it an answer with mockResolvedValue, so fetch answers 404 without leaving the machine. Real network calls make tests slow, flaky and dependent on someone else’s server.
  90. 17. Observing and testing Workers · Try it: test against a fake fetch · Code challengeWrite isDead(url). It sends a HEAD request with fetch and returns true when the response is not ok or when fetch itself fails (rejects); otherwise false.One solution:
    Solution
    async function isDead(url: string): Promise<boolean> {  try {    const response = await fetch(url, { method: 'HEAD' })    return !response.ok  } catch {    return true  }}
    fetch rejects only when no response arrives at all; a 404 or 500 is still a response, with ok set to false. So the code needs both checks: !response.ok for a bad status, and catch for a failed connection. The afterEach puts the real fetch back, so one test’s fake never leaks into the next.
  91. 18. Inside the runtime: isolates, limits and speed · Cold starts are short, and global scope decides how short · QuizA Worker parses a 5 MB JSON file at the top of its module. When does that cost time?B. Once per new isolate, on its first requestTop-level code runs once each time an isolate starts, and isolates start often across hundreds of locations. Every visitor who lands on a new isolate waits for the parse. Load it lazily, or from KV, only when it is needed.
  92. 18. Inside the runtime: isolates, limits and speed · What lives between requests · Find the bugUnder load, visitors sometimes see someone else’s country in the response. Which line?Line 5: const country = String(request.cf?.country ?? 'unknown')The global is shared by every request in the isolate. While one request awaits KV, another overwrites country, and the first replies with the wrong value. Keep request data in local variables.
  93. 18. Inside the runtime: isolates, limits and speed · Web standards first, Node.js on request · QuizA library reads its settings with fs.readFileSync('./config.json'). Will it work in a Worker?B. Not as on a server: a Worker has no project files on disk to read; bundle the data as an import insteadA Worker is a bundle, not a folder of files on a machine. nodejs_compat offers a node:fs module, but it is a virtual file system (the bundle’s own modules under /bundle, and a scratch /tmp), not your project folder. Import the JSON (import config from './config.json') so the bundler includes it.
  94. 18. Inside the runtime: isolates, limits and speed · How Workers are billed, in general terms · QuizWhich change lowers a Worker’s bill the most, all else equal?A. Serving the dashboard’s files as static assets instead of from codeRequests answered by the asset layer are not billed as Worker invocations, and use no CPU of yours. Waiting longer costs nothing, but saves nothing either.
  95. 18. Inside the runtime: isolates, limits and speed · Do independent work at the same time · QuizWhich pair can run in parallel?B. Count a click in D1 and look up the QR image in R2The click count and the image lookup do not need each other’s results. The other two have a dependency: you need the link before redirecting, and the permission before writing.
  96. 19. Security and multi-Worker architecture · Validate every destination · Find the bugThis check lets javascript:alert(1) through. Which line?Line 5: if (url.protocol !== 'https:' && url.protocol !== 'http:') return nullBlocking one bad scheme leaves every other one open. Allow the few you want instead of listing the ones you fear: an allowlist fails safe when someone tries something you did not think of.
  97. 19. Security and multi-Worker architecture · Try it: validate a new link · Code challengeWrite parseNewLink(input). Return { code, url } only when input is an object whose code is 1 to 32 lowercase letters, digits or dashes, and whose url is a string that parses as an http: or https: URL. Return null for anything else.One solution:
    Solution
    type NewLink = { code: string; url: string }
    function parseNewLink(input: unknown): NewLink | null {  if (typeof input !== 'object' || input === null) return null  const { code, url } = input as Record<string, unknown>  if (typeof code !== 'string' || !/^[a-z0-9-]{1,32}$/.test(code)) {    return null  }  if (typeof url !== 'string') return null  try {    const { protocol } = new URL(url)    if (protocol !== 'https:' && protocol !== 'http:') return null  } catch {    return null  }  return { code, url }}
    typeof null is "object", so the null check is needed too. The regular expression ^[a-z0-9-]{1,32}$ is an allowlist for the code: anchored at both ends, so nothing else can sneak in before or after. For the URL, new URL throws on nonsense, and checking the scheme against the two you allow refuses javascript:, data: and any you have not thought of.
  98. 19. Security and multi-Worker architecture · Compare secrets in constant time · QuizWhy is sent === secret a weakness for checking a token?B. Its running time depends on how many leading characters match, which can be measuredThe comparison exits early at the first mismatch. The difference is tiny, but attackers can average many attempts. A constant-time comparison removes the signal.
  99. 19. Security and multi-Worker architecture · Rate limiting at the edge · Fill the blanksRefuse a visitor who creates too many links.1: success, 2: limit, 3: 429limit({ key }) counts one for the key and returns { success }: false when the key is over its limit. 429 Too Many Requests is the status for it; adding a Retry-After header tells clients when to try again.
  100. 19. Security and multi-Worker architecture · Least privilege: each part gets only what it needs · QuizA bug in the public Worker lets a visitor run any code path it has. Why is least privilege worth it here?B. The visitor can only reach what that Worker is bound to: no D1, no secret, no writesA Worker can only touch resources it is bound to. Keeping the public Worker small limits the blast radius of a mistake: the damage is capped by what that one part could do.
  101. 19. Security and multi-Worker architecture · Workers that ship separately need contracts · Put in orderRename the queue message field country to region without losing any data.1. Deploy a consumer that reads region, falling back to country 2. Wait until no older consumer version is running 3. Deploy a producer that sends region instead of country 4. Wait until no messages with only country are left 5. Remove the fallback from the consumerThe reader learns the new shape before any writer uses it, and the writer switches only once no old reader is left (a gradual deployment runs two versions at once). The reader stops accepting the old shape only after the old messages are gone. Each step works with the versions on either side of it.
  102. 19. Security and multi-Worker architecture · Fail gently · QuizThe queue is briefly unavailable and env.CLICKS.send throws. What should the redirect do?B. Redirect anyway, logging the failed send, because the visitor’s redirect matters more than one clickThe click is secondary. Sending it inside ctx.waitUntil with a catch that logs keeps the redirect fast and working. Looping would hold the visitor hostage to a system they do not care about.
  103. 20. Mastery challenge · The staging deploy that cannot sign in · QuizYou ran wrangler secret put ADMIN_TOKEN once, then deployed with --env staging. Every admin call to staging gets 401, and env.ADMIN_TOKEN is undefined there. Why?B. The staging environment is a separate Worker; its secrets are set with wrangler secret put ADMIN_TOKEN --env stagingAn environment deploys as its own Worker, and like bindings and vars, secrets belong to each Worker. Set it again with --env staging. Types only affect the editor, never what is on env at run time.
  104. 20. Mastery challenge · The consumer that loses emails · Find the bugWhen the email service has an outage, some confirmation emails are never sent and never retried. Which line?Line 3Acking before the work tells the queue the message is done, so a failure afterwards is only logged. Ack after sendEmail succeeds, and retry() in the catch, so the queue tries again and finally sends it to the dead letter queue.
  105. 20. Mastery challenge · Choosing the store · QuizThe shortener adds “team links”: links owned by a team, listed and searched in the dashboard by tag and by creation date, edited a few times a day. Where should the list of team links live?B. D1, a links table with team, tag and created_at columnsFiltering and sorting by several fields is what SQL is for. A JSON array in KV must be rewritten whole on each edit (and two edits at once lose one). KV can still hold a copy of each code → url for the fast redirect path, written whenever D1 changes.
  106. 20. Mastery challenge · The rollback that did not help · QuizVersion 12 accidentally answered every link with a 301 to the home page. You roll back to version 11 within ten minutes. Some visitors still land on the home page for days. Why, and what would have avoided it?B. Their browsers cached the 301; answering 302 (or with Cache-Control: private, max-age=60) keeps such mistakes shortA permanent redirect is remembered on the visitor’s device, where no rollback can reach. That is why editable links answer 302 and permanent redirects are reserved for addresses that will truly never change.
  107. 20. Mastery challenge · The redirect that got slower · Find the bugAfter a refactor, redirects take as long as the D1 write again. Which line?Line 5: ctx.waitUntil(countClick(env, code))await countClick(…) finishes the write before waitUntil is even called, so the visitor waits for it. Pass the promise itself to waitUntil, without awaiting.
  108. 20. Mastery challenge · Write the config from memory · Fill the blanksThe redirect Worker reads LINKS, sends to the clicks queue, calls links-api, and is the site at go.example.com.1: kv_namespaces, 2: producers, 3: services, 4: custom_domainKV binds by id under kv_namespaces; a queue producer goes under queues.producers; another Worker is a services entry; and custom_domain: true makes the Worker the whole hostname. No D1 or secrets here: least privilege.
  109. 20. Mastery challenge · Designing a coordinator · QuizEach team may create at most 1,000 links per day, exactly. Requests come from everywhere. Which design is right?B. A Durable Object per team (getByName('team:' + id)) that counts and resets with an alarmThe rate limiting binding is approximate and per location, and KV is eventually consistent: neither is exact. One object for everyone is a bottleneck. One object per team is exact, spreads the load, and an alarm resets the count each day.
  110. 20. Mastery challenge · Shipping a breaking feature safely · Put in orderLinks gain an expires_at column, used by the API, the consumer and the nightly cron. Put the release in order.1. Apply a migration that adds a nullable expires_at column 2. Deploy the API so it writes expires_at when given 3. Upload the consumer and cron Worker’s new version with wrangler versions upload 4. Deploy it gradually, watching wrangler tail --status error 5. Move to 100% and later make the column required for new linksAn additive migration first, so old and new code both run. Writers before readers. Gradual deployment with live error watching, and tightening rules only once everything uses the new shape.