Serverless code on Cloudflare’s network, from your first request to multi-Worker systems.
Starting from what a server and an HTTP request are, this course builds a link shortener on Cloudflare Workers and grows it step by step: routing, settings and secrets, KV, D1, R2, Durable Objects, Queues, Cron Triggers, caching and deploys. It ends with how the runtime works inside, testing, performance, security and designing systems of several Workers.
What you’ll be able to do
Explain what happens between typing an address and seeing a page, and where a Worker runs in that journey.
Write, run and deploy Workers that read requests and build responses with web standard APIs.
Configure a Worker with wrangler.jsonc: vars and secrets, bindings, environments, routes and custom domains.
Store data in the right place: KV, D1 with migrations, R2, and Durable Objects for coordination.
Run work outside the request with waitUntil, Cron Triggers and Queues, and know what each guarantees.
Cache, observe, test, secure and scale Workers, and split a system into Workers joined by service bindings.
Modules
How the web works· 9 slides
The cloud, serverless and the edge· 9 slides
Setting up: a terminal, an account and Wrangler· 9 slides
Your first Worker· 9 slides
Request, Response and URL· 11 slides
Routing by hand· 10 slides
Settings, secrets and the compatibility date· 9 slides
Bindings and Workers KV· 12 slides
D1: a SQL database· 10 slides
Deploying, environments and custom domains· 8 slides
R2: storing files· 9 slides
Background work: waitUntil and Cron Triggers· 10 slides
Queues: work that must happen, later· 10 slides
Durable Objects: one place to coordinate· 10 slides
Static assets and service bindings· 8 slides
Caching and the Cache API· 10 slides
Observing and testing Workers· 11 slides
Inside the runtime: isolates, limits and speed· 9 slides
Security and multi-Worker architecture· 11 slides
Mastery challenge· 10 slides
Interactive parts (exercises, code to run, walkthroughs, flashcards, checklists) are drawn still and marked “Interactive · disabled in the PDF”. Do them in the app; the answers are at the end.
Module 1: How the web works
Cloudflare Workers1 / 194
Module 1 · How the web works
Typing an address starts a conversation
When you type example.com into a browser and press Enter, your computer asks another computer, somewhere else in the world, for a page. That other computer sends the page back, and the browser draws it. Everything on the web is this exchange: someone asks, someone answers.
Every visit to a website is a question and an answer.
This course teaches you to write the computer on the right: the one that answers. With Cloudflare Workers, you write a small program that receives each question and decides the answer. Before any code, the next slides name the parts of the conversation.
Cloudflare Workers2 / 194
Module 1 · How the web works
A server is a program that answers
The side that asks is the client: usually a browser, but also a phone app or another program. The side that answers is the server. The word means both the program that answers and the computer it runs on. A server waits, all day, for questions to arrive.
Client
The program that asks: a browser, an app, a script.
Server
The program (or computer) that waits for questions and answers them.
Request
The question a client sends.
Response
The answer a server sends back. Every request gets exactly one.
QuizInteractive · disabled in the PDF
A weather app on your phone asks a computer at a weather company for today’s forecast. Which is the client?
A weather app on your phone asks a computer at a weather company for today’s forecast. Which is the client?
Cloudflare Workers3 / 194
Module 1 · How the web works
DNS turns names into numbers
Computers on the internet find each other by IP address, a number such as 203.0.113.7. People prefer names like example.com, called domain names. The DNS (Domain Name System) is the internet’s phone book: the browser first asks DNS which number a name belongs to, then talks to that number.
The browser looks the name up before it asks for the page.
Why this matters for Workers. When a domain uses Cloudflare, DNS answers with the address of a Cloudflare computer near the visitor. That is how your Worker ends up running close to whoever is asking.
Cloudflare Workers4 / 194
Module 1 · How the web works
An address has parts
A full web address is a URL (Uniform Resource Locator). Each part tells the server something different. Your Worker will read these parts to decide what to answer.
Text
1https://go.example.com/recipes?from=email#top
One URL, five parts.
Part
Here
What it says
Scheme
https
How to talk: HTTP over an encrypted connection
Host
go.example.com
Which server: the name DNS looks up
Path
/recipes
Which thing on that server
Query
?from=email
Extra details, as name=value pairs
Fragment
#top
A place in the page; never sent to the server
QuizInteractive · disabled in the PDF
In https://shop.example.com/books/42?color=red, what is the path?
In https://shop.example.com/books/42?color=red, what is the path?
Cloudflare Workers5 / 194
Module 1 · How the web works
A request says what you want
Browsers and servers speak HTTP (Hypertext Transfer Protocol): a set of rules for writing requests and responses. A request is text with a method (what you want to do), a path, some headers, and sometimes a body (data you send along).
Text
1GET /recipes?from=email HTTP/1.12Host: go.example.com3User-Agent: Mozilla/5.0 (a browser)4Accept: text/html
A real request, as the server sees it. The first line holds the method and the path.
Method
Means
GET
Give me this. Reading, never changing anything.
POST
Here is some data: create something with it.
PUT / PATCH
Replace or change something that exists.
DELETE
Remove this.
Cloudflare Workers6 / 194
Module 1 · How the web works
A response says how it went
The response starts with a status code: a three-digit number that says how the request went. Then come headers, then the body (the page, the image, the data).
Someone visits a short link that was never created. Which status should the server answer with?
Someone visits a short link that was never created. Which status should the server answer with?
Cloudflare Workers7 / 194
Module 1 · How the web works
Headers are labels on the envelope
Headers are Name: value lines that travel with a request or a response. They describe the message without being the message: what kind of data the body is, how long to keep it, where to go next. Names are not case sensitive: content-type and Content-Type are the same header.
Header
Travels on
Says
Content-Type
both
What the body is: text/html, application/json, image/png
Location
response
Where to go instead (with a 3xx status)
Cache-Control
response
Whether and how long the answer may be kept and reused
User-Agent
request
Which program is asking
Authorization
request
Proof of who is asking, such as a key or token
Put in orderInteractive · disabled in the PDF
Put a visit to https://example.com/ in order.
1.The server answers with a status, headers and a body
2.The browser draws the page
3.The browser asks DNS for the address of example.com
4.The browser sends a GET / request to that address
5.You type the address and press Enter
Cloudflare Workers8 / 194
Module 1 · How the web works
The project: a link shortener
Through this course you will build a link shortener: a service that turns a long address into a short one, like go.example.com/soup. When someone visits the short link, the server answers with a redirect: a 3xx status and a Location header pointing at the long address. The browser then goes there on its own.
A redirect is an answer that says “ask over there”.
First it answers redirects from a list in the code.
Then it keeps its links in a store (KV), counts clicks in a database (D1), keeps images (R2).
Later it cleans up old links on a schedule, sends analytics through a queue, and more.
QuizInteractive · disabled in the PDF
The shortener answers 302. Which header tells the browser where to go?
The shortener answers 302. Which header tells the browser where to go?
Cloudflare Workers9 / 194
Module 1 · How the web works
Recap: how the web works
A client sends a request; a server sends back exactly one response.
DNS turns a domain name into the IP address of a server.
A URL has a scheme, host, path, query and fragment.
A request has a method, a path, headers and maybe a body.
A response has a status code, headers and a body; 2xx worked, 3xx redirect, 4xx client error, 5xx server error.
A redirect is a 3xx status with a Location header.
FlashcardsInteractive · disabled in the PDF
Client
The program that asks: a browser, an app, a script.
Server
The program that waits for requests and answers them.
DNS
The Domain Name System: turns names like example.com into IP addresses.
HTTP
The rules browsers and servers use to write requests and responses.
Status code
Three digits saying how a request went: 200 OK, 404 Not Found, 500 error.
Header
A Name: value line describing a request or response.
Redirect
A 3xx response whose Location header says where to go instead.
Module 2: The cloud, serverless and the edge
Cloudflare Workers10 / 194
Module 2 · The cloud, serverless and the edge
Running your own server is a job of its own
A server can be any computer that stays on and connected. You could run one at home. Then you would have to keep it on day and night, install updates, replace a broken disk, cope when a thousand people arrive at once, and accept that visitors on the other side of the world wait longer for every answer.
Always on: if it switches off, your site is gone.
Upkeep: an operating system to update and secure.
Size: too small and it falls over when busy; too big and you pay for idle time.
Distance: one computer in one city is far from most people.
Each idea in this module removes one of these chores.
Cloudflare Workers11 / 194
Module 2 · The cloud, serverless and the edge
The cloud is someone else’s computers, rented
“The cloud” means computers in large buildings called data centres, owned by a company that rents them out. You do not buy hardware; you rent a slice of a machine by the hour. The company handles power, cooling, cables and broken parts.
Data centre
A building full of servers, with power and fast internet links.
Virtual machine
A pretend computer carved out of a real one, rented to you.
Provider
The company renting out the computers: Cloudflare is one.
Note. With a rented machine you still choose its size, install software on it and keep it updated. Serverless, next, takes even that away.
Cloudflare Workers12 / 194
Module 2 · The cloud, serverless and the edge
Serverless: you bring code, they run it when asked
With serverless, you never see a machine. You hand the provider a piece of code. When a request arrives, the provider runs your code to answer it; when no one is asking, nothing runs and you pay nothing. There are still servers, of course: they are just not yours to look after.
A rented server
You pick its size in advance
It runs (and costs) all the time
You install and update its software
Busy day: you add machines
Serverless
No size to pick
Runs only while answering requests
The provider keeps the software current
Busy day: the provider runs more copies
QuizInteractive · disabled in the PDF
A serverless site gets no visitors at all for a week. What is it doing?
A serverless site gets no visitors at all for a week. What is it doing?
Cloudflare Workers13 / 194
Module 2 · The cloud, serverless and the edge
The edge: running close to the person asking
Messages over the internet travel fast, but not instantly: a request from Sydney to a server in London and back takes a noticeable fraction of a second. That delay is called latency. The edge means running code in many cities at once, so each request is answered by a computer near the person who sent it.
One program, copied to every location. Each visitor reaches the nearest.
Latency
The time a message takes to travel there and back.
Edge
Computers spread across many cities, close to users.
Cloudflare Workers14 / 194
Module 2 · The cloud, serverless and the edge
A Worker is your code on Cloudflare’s network
Cloudflare runs data centres in hundreds of cities. Cloudflare Workers lets you deploy a small program to all of them at once. Each request that reaches Cloudflare for your address runs your program, in the city nearest the visitor, and your program writes the response. It is serverless and on the edge at the same time.
Storage and other servicesused by the Worker when it needs data
Where a Worker sits between a visitor and everything else.
QuizInteractive · disabled in the PDF
You deploy one Worker. A visitor in Nairobi opens your site. Where does your code run?
You deploy one Worker. A visitor in Nairobi opens your site. Where does your code run?
Cloudflare Workers15 / 194
Module 2 · The cloud, serverless and the edge
Workers start in milliseconds
Many serverless services start a whole small computer (a container) for your code, which can take seconds the first time: a cold start. Workers instead run each program in an isolate: a sealed-off space inside one shared JavaScript engine, the same engine (V8) that runs JavaScript in Chrome. Thousands of isolates share one process, and a new one starts in a few milliseconds.
A container per program
A small operating system each
Starts in hundreds of milliseconds or more
Any language, any library
An isolate per program
Only your code and its memory
Starts in a few milliseconds
Web standard JavaScript APIs, with limits
Expert note. The expert modules return to isolates: what they share, what lives between requests, and how libraries written for Node.js run in a Worker.
Cloudflare Workers16 / 194
Module 2 · The cloud, serverless and the edge
A Worker comes with a toolbox
A program that only computes answers is limited. Real services remember things, store files and do work later. Cloudflare offers storage and helpers that a Worker plugs into, and this course covers each one with the link shortener.
Tool
What it is for
In the shortener
KV
Fast reads of small values by key, everywhere
The links themselves
D1
A SQL database
Click counts and reports
R2
Files, small or very large
QR code images
Durable Objects
One coordinator per name
Exact live counters
Queues
Messages handled later
Analytics events
Cron Triggers
Code on a schedule
Nightly clean-up
QuizInteractive · disabled in the PDF
Which of these are good jobs for a Worker? Pick all that apply.
Which of these are good jobs for a Worker? Pick all that apply.
Cloudflare Workers17 / 194
Module 2 · The cloud, serverless and the edge
You write Workers in JavaScript or TypeScript
JavaScript is the language of the web. TypeScript is JavaScript with types: labels that say what kind of value each thing is (string, number, a Request), checked before the code runs, so many mistakes show up in your editor instead of in front of visitors. This course uses TypeScript; Wrangler, Cloudflare’s tool, turns it into JavaScript for you.
TypeScript
1// JavaScript would accept this and fail later.2// TypeScript underlines it at once: 42 is not a string.3const shortCode:string=42
A type, written after a colon, says what a value must be.
QuizInteractive · disabled in the PDF
When does TypeScript check the types in your code?
When does TypeScript check the types in your code?
Cloudflare Workers18 / 194
Module 2 · The cloud, serverless and the edge
Recap: the cloud, serverless and the edge
The cloud is rented computers in data centres.
Serverless: you give code, the provider runs it per request; idle costs nothing.
The edge: code runs in many cities, close to each visitor, to cut latency.
A Worker is serverless code deployed to all of Cloudflare’s locations.
Workers run in isolates, which start in milliseconds.
Storage and helpers (KV, D1, R2, Durable Objects, Queues, Cron) plug into a Worker.
FlashcardsInteractive · disabled in the PDF
The cloud
Computers in data centres, rented from a provider.
Serverless
You deploy code; the provider runs it when requests arrive and you never manage a machine.
Latency
How long a message takes to go there and back.
The edge
Running code in many locations near users.
Isolate
A sealed-off space in a shared JavaScript engine where one Worker runs.
Cold start
The delay when a program must be started before it can answer its first request.
TypeScript
JavaScript plus types, checked before the code runs.
Module 3: Setting up: a terminal, an account and Wrangler
Cloudflare Workers19 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
The terminal is where you type commands
A terminal is a window where you type instructions to your computer as text, instead of clicking. Each instruction is a command: a program’s name, then options. On macOS open Terminal, on Windows PowerShell or Windows Terminal, on Linux your terminal app. In this course, lines in a bash block are commands to type, one at a time, pressing Enter after each.
Terminal
1# A line starting with # is a comment: don't type it.2node--version# prints something like v22.12.03cdprojects# "change directory": move into a folder4ls# list the files here (dir on Windows)
Terminal
A text window for running commands.
Command
A program name plus options, run by pressing Enter.
Directory
Another word for a folder.
Cloudflare Workers20 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
Node.js and npm run the tools
Your Worker runs on Cloudflare, but the tools that build and test it run on your computer, in Node.js: a program that runs JavaScript outside a browser. Install the current LTS (long-term support) version from nodejs.org. It comes with npm, which downloads code libraries (called packages), and npx, which runs a package’s command.
Command
Does
npm install
Downloads every package the project lists into node_modules/
npm install some-package
Adds one package to the project
npx some-tool
Runs a tool from the project’s packages
npm run dev
Runs the script named dev in package.json
QuizInteractive · disabled in the PDF
Is Node.js what runs your Worker on Cloudflare?
Is Node.js what runs your Worker on Cloudflare?
Cloudflare Workers21 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
A free Cloudflare account
Sign up at dash.cloudflare.com. The dashboard is the website where you see and manage everything you have on Cloudflare. The Free plan includes Workers, with a daily allowance of requests, which is plenty to learn on. You do not need to own a domain name: every account gets a free address ending in workers.dev.
Your workers.dev subdomain. The first time you open Workers in the dashboard, you choose a subdomain, such as ana. Each Worker you deploy is then reachable at <worker-name>.ana.workers.dev.
Try itInteractive · disabled in the PDF
Cloudflare Workers22 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
One command makes a project
npm create cloudflare runs Cloudflare’s project maker, called C3. It asks a few questions, then writes a folder with a working Worker, installs its packages and can set up git (a tool that keeps the history of your files).
Terminal
1npmcreatecloudflare@latest--link-shortener2# What would you like to start with? Hello World example3# Which template? Worker only4# Which language? TypeScript5# Do you want to deploy? No (not yet)6cdlink-shortener
The -- passes the folder name to C3 rather than to npm.
Fill the blanksInteractive · disabled in the PDF
Make a new Worker project called link-shortener, then move into it.
Module 3 · Setting up: a terminal, an account and Wrangler
What is in the new folder
link-shortener/
src/
index.ts# your Worker's code
test/# tests (a later module)
wrangler.jsonc# describes the Worker to Cloudflare
worker-configuration.d.ts# types made by Wrangler
package.json# the project's packages and scripts
tsconfig.json# TypeScript's settings
node_modules/# downloaded packages: never edit
Exact files vary a little with the template version.
QuizInteractive · disabled in the PDF
Which file says what the Worker is called and where its code starts?
Which file says what the Worker is called and where its code starts?
Cloudflare Workers24 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
Wrangler is Cloudflare’s command-line tool
Wrangler runs your Worker on your computer, creates storage, deploys and shows logs. C3 installs it into the project, so you run it with npx wrangler. Before it can act on your account, log in once: it opens the browser and asks you to allow access.
Terminal
1npxwrangler--version# this course uses Wrangler 42npxwranglerlogin# opens the browser to allow access3npxwranglerwhoami# which account you are logged in to
Fill the blanksInteractive · disabled in the PDF
Log in, then check which account Wrangler will use.
Terminal
1npx2npxwrangler
Cloudflare Workers25 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
wrangler.jsonc describes your Worker
JSON is a text format for data: names in double quotes, values after colons, { } around groups, [ ] around lists. JSONC is JSON that also allows // comments. Wrangler 4 reads wrangler.jsonc (recommended for new projects) or the older wrangler.toml.
Lines 2:$schema lets your editor suggest and check every setting.
Lines 3:name: the Worker’s name, unique in your account and part of its address.
Lines 4:main: the file where the Worker’s code starts.
Lines 5:compatibility_date: which version of the runtime’s behaviour your code expects. A later module explains it.
Lines 6: Keep the Worker’s logs so you can search them in the dashboard.
Cloudflare Workers26 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
Reading an error message
Errors are normal and they are information. Read the first line that says error and the last line of the output: one usually names the problem, the other the file and line. Most early errors are one of three kinds: the command was run in the wrong folder, a tool is missing, or you are not logged in.
You see
It usually means
command not found: node
Node.js is not installed, or the terminal was open before you installed it
Missing entry-point to Worker script
Wrangler found no config: you are not inside the project folder, so cd into it
You are not authenticated
Run npx wrangler login
Cannot find module
Packages missing: run npm install
QuizInteractive · disabled in the PDF
You open a new terminal and run npx wrangler dev. It stops with Missing entry-point to Worker script. What is the most likely fix?
You open a new terminal and run npx wrangler dev. It stops with Missing entry-point to Worker script. What is the most likely fix?
Cloudflare Workers27 / 194
Module 3 · Setting up: a terminal, an account and Wrangler
Recap: setting up
A terminal runs commands; cd moves between folders.
Node.js runs the tools on your computer; npm installs packages; npx runs them.
A free Cloudflare account gives you a workers.dev address.
npm create cloudflare@latest makes a working project.
Wrangler runs, deploys and manages Workers; wrangler login connects it to your account.
wrangler.jsonc names the Worker, its entry file and its compatibility date.
FlashcardsInteractive · disabled in the PDF
npm
Node’s package manager: downloads libraries listed in package.json.
npx
Runs a command from an installed package: npx wrangler dev.
JSON that allows comments; the format of wrangler.jsonc.
main
The setting naming the file where a Worker’s code starts.
Dashboard
Cloudflare’s website for managing your account and Workers.
Module 4: Your first Worker
Cloudflare Workers28 / 194
Module 4 · Your first Worker
Four JavaScript pieces you need first
A Worker uses only a few language features at first. If you have written JavaScript before, skim this slide.
Function
A named set of steps you can run, with inputs (parameters) and an output (return).
Object
A group of named values in { }, such as { name: 'soup', clicks: 3 }.
Method
A function that lives inside an object.
Promise
A value that is not ready yet, like a ticket for a coat. await waits for it; a function that awaits is marked async.
Module
A file of code. export makes something in it available to other code.
TypeScript
1const link ={code:'soup',clicks:3}// an object2functiondouble(n:number){// a function3return n *24}5double(link.clicks)// 6
Cloudflare Workers29 / 194
Module 4 · Your first Worker
The whole Worker, line by line
WalkthroughInteractive · disabled in the PDF
src/index.ts
1exportdefault{2asyncfetch(request, env, ctx){3returnnewResponse('Hello from the edge')4}5}satisfiesExportedHandler<Env>
Lines 1:export default hands this object to the runtime. The runtime looks inside it for handlers: methods with known names.
Lines 2:fetch is the handler for web requests. The runtime calls it once per request, with three arguments: the request, env and ctx.
Lines 3: It returns a Response: here, the text “Hello from the edge” with status 200, the default.
Lines 5:satisfies ExportedHandler<Env> asks TypeScript to check the object has the right shape, and gives the three arguments their types.
Note. There is no listen or port in the code. Cloudflare receives the request and calls your fetch: you only write what to answer.
Cloudflare Workers30 / 194
Module 4 · Your first Worker
Try it: be the runtime
A Worker’s fetch is a plain function, so you can call it yourself: build a Request, call fetch with it, and read the Response that comes back. That is all the runtime does for each visitor. Cloudflare’s own types (ExportedHandler, Env) are not on this page, so the argument is typed by hand as a Request.
PlaygroundInteractive · disabled in the PDF
TypeScript
1const worker ={2asyncfetch(request:Request):Promise<Response>{3returnnewResponse('Hello from the edge')4}5}67// What the runtime does for each visitor, by hand:8const request =newRequest('https://go.example.com/soup')9worker.fetch(request).then(async(response)=>{10 console.log(response.status)// 20011 console.log(await response.text())// Hello from the edge12})
Change the text, or add { status: 404 } as a second argument to Response, and run it again.
Cloudflare Workers31 / 194
Module 4 · Your first Worker
wrangler dev runs it on your computer
npx wrangler dev starts a local copy of the Workers runtime and serves your Worker at http://localhost:8787. localhost means “this computer”, and 8787 is the port, a numbered door on it. Save a change to src/index.ts and the Worker reloads by itself. Press x (or Ctrl+C) to stop.
Terminal
1npxwranglerdev2# Ready on http://localhost:878734# in a second terminal, ask it something:5curlhttp://localhost:8787/6# Hello from the edge
curl sends a request from the terminal and prints the response body.
Try itInteractive · disabled in the PDF
Cloudflare Workers32 / 194
Module 4 · Your first Worker
Make it redirect
A shortener answers with a redirect. Response.redirect(url, status) builds one: it sets the status and the Location header for you. Use 302 (Found, temporary) while you are still changing things; browsers remember a 301 (Moved Permanently) and may never ask again.
You run curl -i http://localhost:8787/anything against this Worker. What comes back?
You run curl -i http://localhost:8787/anything against this Worker. What comes back?
Cloudflare Workers33 / 194
Module 4 · Your first Worker
wrangler deploy puts it on the internet
npx wrangler deploy turns your TypeScript into JavaScript, bundles it (joins your code and its packages into one file), uploads it with the settings from wrangler.jsonc, and switches the Worker to the new code everywhere, usually within seconds.
1.It uploads the bundle and settings to your account
2.Cloudflare starts sending requests to the new code
3.Visitors to the workers.dev address get the new response
4.Wrangler reads wrangler.jsonc
5.It bundles src/index.ts and its packages into one file
Cloudflare Workers34 / 194
Module 4 · Your first Worker
Request, env and ctx
Argument
What it is
Module
request
The incoming request: method, URL, headers, body
Next module
env
Settings, secrets and connections to storage
Config and KV modules
ctx
The execution context: lets work continue after the response
Background work
Env is a type that lists what is on env. Wrangler writes it for you into worker-configuration.d.ts when you run npx wrangler types (the template’s npm run cf-typegen script does the same).
An exception is an error raised while code runs: reading a property of undefined, a failed network call. If fetch throws and nothing catches it, the visitor gets a Cloudflare error page (error 1101, “Worker threw exception”). Locally, wrangler dev prints the error and a stack trace: the list of functions that were running, with file and line, newest first.
Text
1✘ [ERROR] TypeError: Cannot read properties of undefined2 (reading 'url')3 at Object.fetch (src/index.ts:3:25)
Read it as: what went wrong, then where (file, line, column).
Find the bugInteractive · disabled in the PDF
The browser shows an error instead of the redirect. Which line is wrong?
TypeScript
Cloudflare Workers36 / 194
Module 4 · Your first Worker
Recap: your first Worker
A Worker is a module whose default export holds handlers.
fetch(request, env, ctx) runs once per request and returns a Response.
Response.redirect(url, 302) builds a redirect.
npx wrangler dev runs it at localhost:8787; npx wrangler deploy publishes it.
An uncaught exception becomes Cloudflare’s error 1101 page; the stack trace says where.
FlashcardsInteractive · disabled in the PDF
Handler
A method on the default export the runtime calls for an event: fetch for web requests.
localhost:8787
Where wrangler dev serves your Worker on your own computer.
Bundle
Your code and its packages joined into one file for upload.
302 vs 301
302 is a temporary redirect; 301 is permanent and browsers remember it.
Exception
An error raised while code runs; uncaught in fetch, it becomes error 1101.
Stack trace
The functions that were running when an error happened, with file and line.
Module 5: Request, Response and URL
Cloudflare Workers37 / 194
Module 5 · Request, Response and URL
The same objects as in the browser
Workers use web standard APIs: objects defined once for the whole web, the same ones a browser offers. Request, Response, URL, Headers and the fetch function work the same in a Worker as in browser code. What you learn here works in many other places too.
API
Application Programming Interface: the functions and objects a system offers to code.
Web standard
An API agreed for the whole web, behaving the same everywhere it exists.
Property
A named value on an object, read with a dot: request.method.
Cloudflare Workers38 / 194
Module 5 · Request, Response and URL
Reading the request
TypeScript
1exportdefault{2asyncfetch(request, env, ctx){3 request.method // "GET"4 request.url // "https://go.example.com/soup"5 request.headers.get('user-agent')// "Mozilla/5.0 ..."6 request.headers.get('x-missing')// null: no such header7returnnewResponse('ok')8}9}satisfiesExportedHandler<Env>
null means “nothing there”: check for it before using the value.
request.url is the full address as one string. To pull out the path or the query, turn it into a URL object, next.
Time to write a handler. The tests play the runtime: they build a Request and call worker.fetch, then read the Response you return.
Code challengeInteractive · disabled in the PDF
Finish 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.
A POST request usually carries a body. Read it with await request.json() (data in JSON), await request.text() (plain text) or await request.formData() (an HTML form). The body is a stream: data that arrives in pieces and is used up as it is read. A second read throws.
TypeScript
1// a client sends: {"code":"soup","url":"https://example.com/soup"}2const data =await request.json<{ code:string; url:string}>()3data.code // "soup"
The type in < > describes what you expect. It does not check it: a later module validates input.
Predict the outputInteractive · disabled in the PDF
What happens when a Worker runs these two lines on one request?
TypeScript
1const text =await request.text()2const data =await request.json()
What happens when a Worker runs these two lines on one request?
Cloudflare Workers42 / 194
Module 5 · Request, Response and URL
Building a response
TypeScript
1newResponse('Not found',{status:404})2newResponse('<h1>Hi</h1>',{3headers:{'Content-Type':'text/html; charset=utf-8'}4})5Response.json({code:'soup',clicks:3})// sets JSON type6Response.json({error:'taken'},{status:409})7Response.redirect('https://example.com/',302)8newResponse(null,{status:204})// no body
The second argument holds options: status and headers.
Fill the blanksInteractive · disabled in the PDF
Answer with JSON and a 201 Created status.
TypeScript
1return Response.({code:'soup'},{:})
Cloudflare Workers43 / 194
Module 5 · Request, Response and URL
Try it: read JSON, answer JSON
In a Worker, request.json<NewLink>() takes a type. That is a Workers addition: the standard json() on this page returns any, so the code below names the type with as. Either way, nothing checks that the body really has that shape.
Code challengeInteractive · disabled in the PDF
Finish 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.
TypeScript
1typeNewLink={ code:string}23const worker ={4asyncfetch(request:Request):Promise<Response>{5// TODO: read the body, then answer 201 with the short URL6returnnewResponse('Not done yet',{status:501})7}8}
Cloudflare Workers44 / 194
Module 5 · Request, Response and URL
Headers on a response you did not build
A Headers object has get, set, has and delete. On a response you build, you can change them. A response that came back from fetch is immutable (cannot be changed): to change its headers, copy it first with new Response(old.body, old), which keeps the status, the headers and the body stream.
Throws a TypeError
const res =awaitfetch('https://example.com/')res.headers.set('X-Served-By','shortener')return res
Copy, then change
const upstream =awaitfetch('https://example.com/')const res =newResponse(upstream.body, upstream)res.headers.set('X-Served-By','shortener')return res
Cloudflare Workers45 / 194
Module 5 · Request, Response and URL
fetch() asks another server
A Worker can be a client too. The global fetch(url, options) sends a request and gives back a promise of a Response. A request your Worker makes is a subrequest. While your Worker waits for the answer, it uses no CPU time (more on that in the limits module).
TypeScript
1// Does the long link still work?2const check =awaitfetch('https://example.com/soup',{3method:'HEAD',// headers only, no body4redirect:'manual'// don't follow redirects5})6check.ok // true for any 2xx status7check.status // 200
QuizInteractive · disabled in the PDF
A fetch gets back a 404. What is response.ok, and does fetch throw?
A fetch gets back a 404. What is response.ok, and does fetch throw?
Cloudflare Workers46 / 194
Module 5 · Request, Response and URL
What Cloudflare adds to the request
Cloudflare adds facts about each visitor. request.cf is an object with the visitor’s country, city, the colo (the Cloudflare location that took the request) and more. The CF-Connecting-IP header holds the visitor’s IP address. Locally, wrangler dev fills these with sample values.
TypeScript
1const country = request.cf?.country ??'unknown'// "PE"2const ip = request.headers.get('cf-connecting-ip')
?. reads a property only if the object exists; ?? gives a fallback for null or undefined.
Find the bugInteractive · disabled in the PDF
This Worker should greet visitors at /hello and give a 404 elsewhere, but every path gets a 404. Which line?
TypeScript
Cloudflare Workers47 / 194
Module 5 · Request, Response and URL
Recap: Request, Response and URL
Workers use web standard objects: Request, Response, URL, Headers, fetch.
new URL(request.url) gives pathname and searchParams.
A body is a stream: read it once with json(), text() or formData().
new Response(body, { status, headers }), Response.json and Response.redirect build answers.
Fetched responses are immutable: copy with new Response(res.body, res) to change headers.
fetch rejects only when no response arrives; check ok and status.
FlashcardsInteractive · disabled in the PDF
Web standard API
An API defined for the whole web, the same in browsers and Workers.
url.pathname
The path of a URL, always starting with /.
url.searchParams
The query string as an object: get, getAll, has.
Stream
Data that arrives in pieces and is used up as you read it.
Subrequest
A request your Worker makes with fetch while handling one.
response.ok
True when the status is from 200 to 299.
request.cf
Facts Cloudflare adds about the visitor: country, city, location.
Module 6: Routing by hand
Cloudflare Workers48 / 194
Module 6 · Routing by hand
A route is a method and a path
Routing is choosing which piece of code answers a request. A route pairs a method and a path pattern with the code that answers it. The shortener needs a few routes; each is a different job.
Method
Path
Does
GET
/
A short page about the service
GET
/health
Answers ok, so monitors can check it is up
GET
/:code
Redirects a short code (/soup) to its long link
POST
/api/links
Creates a new short link
anything else
404 Not Found
Route
A method and path pattern, plus the code that answers it.
Path parameter
A part of the path that varies, written :code in a pattern.
Lines 1-4: The links, for now, are an object in the code. Record<string, string> is TypeScript for “keys are strings, values are strings”.
Lines 8:const { pathname } = … takes just pathname out of the URL object (this is called destructuring).
Lines 9: The most specific routes go first.
Lines 10-11:slice(1) drops the first character, the slash. Looking up a missing key gives undefined.
Lines 12-14: Only a GET for a known code redirects.
Lines 15: Everything that fell through is a 404.
Cloudflare Workers50 / 194
Module 6 · Routing by hand
Order matters
Checks run top to bottom and the first match answers. A catch-all route like /:code matches almost anything, so it must come after specific ones like /health, or health would be treated as a short code.
Predict the outputInteractive · disabled in the PDF
With the routes checked in this order, what does GET /health return?
TypeScript
1const code = pathname.slice(1)2if(code)returnredirectOrNotFound(code)// any /something3if(pathname ==='/health')returnnewResponse('ok')
With the routes checked in this order, what does GET /health return?
Cloudflare Workers51 / 194
Module 6 · Routing by hand
Paths arrive encoded
Some characters cannot appear raw in a URL, so browsers percent-encode them: a space becomes %20, é becomes %C3%A9. pathname gives you the encoded form. decodeURIComponent turns it back, and throws on broken encodings like %E0%A4%A, so catch that and answer 400.
TypeScript
1newURL('https://go.example.com/caf%C3%A9').pathname // "/caf%C3%A9"2decodeURIComponent('caf%C3%A9')// "café"34functioncodeFrom(pathname:string):string|null{5try{6returndecodeURIComponent(pathname.slice(1))7}catch{8returnnull// a broken encoding: answer 4009}10}
Fill the blanksInteractive · disabled in the PDF
Turn the path /caf%C3%A9 into the code café.
TypeScript
1const code =(pathname.(1))
Cloudflare Workers52 / 194
Module 6 · Routing by hand
The wrong method is a 405
If the path exists but the method does not fit (a DELETE on a page that only reads), the right answer is 405 Method Not Allowed, with an Allow header listing what would work. A 404 would wrongly say the path does not exist.
TypeScript
1if(pathname ==='/api/links'){2if(request.method ==='POST')returncreateLink(request, env)3returnnewResponse('Method not allowed',{4status:405,5headers:{Allow:'POST'}6})7}
QuizInteractive · disabled in the PDF
A client sends PUT /health. /health only answers GET. Which status is right?
A client sends PUT /health. /health only answers GET. Which status is right?
Cloudflare Workers53 / 194
Module 6 · Routing by hand
Try it: a small router
Put the last three slides together: specific routes first, the right status for a wrong method, the catch-all last.
Code challengeInteractive · disabled in the PDF
Finish 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.
TypeScript
1const LINKS:Record<string,string>={2soup:'https://example.com/recipes/tomato-soup'3}45const worker ={6asyncfetch(request:Request):Promise<Response>{7const{ pathname }=newURL(request.url)8if(pathname ==='/health')returnnewResponse('ok')9// TODO: /api/links (POST only), then GET /<code>10returnnewResponse('Not found',{status:404})11}12}
Cloudflare Workers54 / 194
Module 6 · Routing by hand
URLPattern matches paths with parameters
URLPattern is a web standard built into Workers. You write a pattern with named parts, and exec tells you whether a URL matches and what each part was.
Make patterns once. Create patterns at the top of the file, outside fetch, so they are built once per isolate rather than on every request.
Cloudflare Workers55 / 194
Module 6 · Routing by hand
Catch errors once, at the top
Wrap the routing in try/catch so an unexpected error becomes your own 500 response, and gets logged, instead of Cloudflare’s error page. Mind the await: without it, a promise that fails later escapes the try.
Find the bugInteractive · disabled in the PDF
When route rejects, visitors still get Cloudflare’s error page instead of the JSON. Which line?
TypeScript
Cloudflare Workers56 / 194
Module 6 · Routing by hand
When to use a router library
Routing by hand is fine for a handful of routes, and it shows you what any router does. With dozens, you want parameters, shared steps (logging, auth) before many routes, and one place for errors. Libraries like Hono give you that, and their app is still a fetch handler underneath.
TypeScript
1import{ Hono }from'hono'23const app =newHono<{ Bindings:Env}>()4app.get('/health',(c)=> c.text('ok'))5app.get('/:code',(c)=>redirectFor(c.env, c.req.param('code')))67exportdefault app // app.fetch is the fetch handler
The same routes with Hono, which has its own course.
Put in orderInteractive · disabled in the PDF
Order the checks in a hand-written router, from first to last.
1.Exact routes such as /health
2.The 404 fallback
3.Routes with parameters such as /api/links/:code/stats
4.The catch-all /:code
5.Parse the URL
Cloudflare Workers57 / 194
Module 6 · Routing by hand
Recap: routing by hand
A route pairs a method and a path pattern with code.
Checks run in order: specific routes before catch-alls.
decodeURIComponent reverses percent-encoding and can throw.
Wrong method on a real path: 405 with an Allow header.
URLPattern matches paths with named parameters.
return await inside try so failures reach your catch.
FlashcardsInteractive · disabled in the PDF
Routing
Choosing which code answers a request, from its method and path.
Percent-encoding
Writing special characters in a URL as % plus hex: a space is %20.
405
Method Not Allowed: the path exists but not for this method.
URLPattern
A web standard that matches URLs against patterns like /links/:code.
Destructuring
Taking named values out of an object: const { pathname } = url.
return await
Waits for the promise inside try, so its error can be caught.
Module 7: Settings, secrets and the compatibility date
Cloudflare Workers58 / 194
Module 7 · Settings, secrets and the compatibility date
Settings belong outside the code
Some values change between your laptop and the real site, or between today and next month: the public address of the service, a limit, a key for another company’s API. If they are written into the code, changing one means changing code. Workers keep them in the config and hand them to your code on env.
Environment variable (var)
A named setting given to the program from outside its code.
Secret
A setting that must stay private, such as a password or API key: stored encrypted, never shown again.
`env`
The second argument of fetch: every var, secret and connection the Worker has.
Cloudflare Workers59 / 194
Module 7 · Settings, secrets and the compatibility date
1const shortUrl =`${env.PUBLIC_BASE}/${code}`2const max =Number(env.MAX_CODE_LENGTH)// "32" -> 323const open = env.SIGNUPS_OPEN ==='true'
Backticks make a template string: ${…} puts a value inside it.
QuizInteractive · disabled in the PDF
A var is set to "SIGNUPS_OPEN": "false". What does if (env.SIGNUPS_OPEN) { … } do?
A var is set to "SIGNUPS_OPEN": "false". What does if (env.SIGNUPS_OPEN) { … } do?
PlaygroundInteractive · disabled in the PDF
TypeScript
1// The vars from the config above: text, because they are in quotes2const env ={3SIGNUPS_OPEN:'false',4MAX_CODE_LENGTH:'32'5}67console.log(Boolean(env.SIGNUPS_OPEN))// true: not empty8console.log(env.SIGNUPS_OPEN ==='true')// false9console.log(env.MAX_CODE_LENGTH +1)// "321": text joined10console.log(Number(env.MAX_CODE_LENGTH)+1)// 33
Try SIGNUPS_OPEN: '' (empty text) and run it again.
Cloudflare Workers60 / 194
Module 7 · Settings, secrets and the compatibility date
Secrets never go in the config
wrangler.jsonc is usually kept in git and visible in the dashboard, so it must never hold a secret. Set secrets with wrangler secret put: it asks for the value (so it never lands in your terminal history), stores it encrypted, and gives the Worker a new version with it. Your code reads it on env like any var.
Terminal
1npxwranglersecretputADMIN_TOKEN2# ? Enter a secret value: ********3npxwranglersecretlist# names only, never the values4npxwranglersecretdeleteADMIN_TOKEN
Fill the blanksInteractive · disabled in the PDF
Store the token the admin API checks.
Terminal
1npxwranglerADMIN_TOKEN
Cloudflare Workers61 / 194
Module 7 · Settings, secrets and the compatibility date
Local secrets live in .dev.vars
wrangler dev does not read your production secrets. It reads a file named .dev.vars (or .env; use one, not both) in the project folder, one NAME=value per line. Add it to .gitignore, the list of files git must not save, and commit a .dev.vars.example with harmless placeholders so others know what to fill in.
link-shortener/
.dev.vars# real local values: git-ignored
.dev.vars.example# placeholders: committed
.gitignore# lists .dev.vars
wrangler.jsonc
.dev.vars.example
1# Copy to .dev.vars and fill in. Never commit .dev.vars.2ADMIN_TOKEN=pick-any-long-local-value
Cloudflare Workers62 / 194
Module 7 · Settings, secrets and the compatibility date
Using a secret to guard a route
Only the owner should create links. A simple guard: the client sends the token in the Authorization header, as Bearer <token>, and the Worker compares it with the secret.
Find the bugInteractive · disabled in the PDF
This works, but one line is a security problem. Which?
TypeScript
Cloudflare Workers63 / 194
Module 7 · Settings, secrets and the compatibility date
Try it: check the token
Code that reads env is easy to test: hand it a fake env with harmless values. Here Env is written by hand; in a project, wrangler types writes it.
Code challengeInteractive · disabled in the PDF
Write 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.
TypeScript
1interfaceEnv{2 ADMIN_TOKEN?:string3}45functionisAdmin(request:Request, env:Env):boolean{6// TODO: compare the Authorization header with the secret7returnfalse8}
Cloudflare Workers64 / 194
Module 7 · Settings, secrets and the compatibility date
wrangler types writes the Env type
npx wrangler types reads the config (and the names in .dev.vars) and writes worker-configuration.d.ts: an Env type listing every var, secret and binding, plus the types of the runtime itself for your compatibility date. Run it again whenever the config changes.
You add a var to the config and deploy, but forget to run wrangler types. What happens?
You add a var to the config and deploy, but forget to run wrangler types. What happens?
Cloudflare Workers65 / 194
Module 7 · Settings, secrets and the compatibility date
The compatibility date pins the runtime’s behaviour
Cloudflare keeps improving the runtime. Changes that could break existing code are tied to a date: compatibility_date says “behave as the runtime did on this day”. Time passing never changes your Worker. Single changes can also be switched on early with compatibility_flags.
Move the date forward on purpose, not by accident.
Read what changed between the old and new dates in Cloudflare’s compatibility dates page.
Run your tests, then deploy.
QuizInteractive · disabled in the PDF
A Worker was deployed two years ago and never touched. Since then, the runtime changed a default that would break it. What happens?
A Worker was deployed two years ago and never touched. Since then, the runtime changed a default that would break it. What happens?
Cloudflare Workers66 / 194
Module 7 · Settings, secrets and the compatibility date
Recap: settings and secrets
Vars are plain settings in wrangler.jsonc, read on env.
A quoted "false" is a truthy string: compare explicitly.
Secrets are set with wrangler secret put, never in config or code.
Locally, secrets come from a git-ignored .dev.vars.
wrangler types writes the Env type; rerun it when config changes.
compatibility_date freezes runtime behaviour until you move it.
FlashcardsInteractive · disabled in the PDF
Var
A plain setting in wrangler.jsonc under vars, read as env.NAME.
Secret
A private value set with wrangler secret put, stored encrypted.
.dev.vars
The git-ignored file of local secrets that wrangler dev reads.
.gitignore
The list of files git must not save.
wrangler types
Generates worker-configuration.d.ts with the Env type.
Compatibility date
The day whose runtime behaviour your Worker keeps.
401
Unauthorized: the request lacks valid credentials.
Module 8: Bindings and Workers KV
Cloudflare Workers67 / 194
Module 8 · Bindings and Workers KV
A binding is a connection waiting on env
Normally, to use a database you need its address, a username and a password in your code. Workers use bindings instead: you declare in wrangler.jsonc that the Worker may use a resource, give it a name, and the runtime puts a ready-made object on env under that name. No address, no keys, nothing to leak.
The config grants access; the code just uses env.LINKS.
Binding
A named connection to a resource, declared in config and found on env.
Resource
Something the Worker uses: a store, a database, a bucket, another Worker.
Cloudflare Workers68 / 194
Module 8 · Bindings and Workers KV
KV: a giant dictionary, everywhere
Workers KV stores values under keys, like a dictionary: you look up soup and get back its long link. Values are copied to Cloudflare’s locations as they are read, so popular keys come back in a few milliseconds anywhere in the world. It is made for data read often and written rarely, which is exactly what links are.
Good at
Not made for
Many fast reads of the same keys
Counting things that change every second
Settings, links, cached pages
Searching or sorting by value
Values up to 25 MiB
Changes that must be seen everywhere at once
Key-value store
Storage where each value is saved and fetched by a unique key.
Namespace
One KV store: its own set of keys, bound to Workers by id.
Cloudflare Workers69 / 194
Module 8 · Bindings and Workers KV
Create a namespace and bind it
Terminal
1npxwranglerkvnamespacecreateLINKS2# prints the namespace's id and the lines to add to the config
wrangler.jsonc
1"kv_namespaces": [2{"binding":"LINKS","id":"<the id Wrangler printed>"}3]
Then run npx wrangler types, and env.LINKS is typed as a KVNamespace.
Real KV is not on this page, so the tests hand your Worker a stand-in: an object with the same get and delete as a KVNamespace, keeping its keys in a Map (a key-value collection in memory). Your code cannot tell the difference, which is the point of a binding.
Code challengeInteractive · disabled in the PDF
Finish 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.
TypeScript
1// The part of KVNamespace this Worker uses2interfaceKV{3get(key:string):Promise<string|null>4delete(key:string):Promise<void>5}6interfaceEnv{7 LINKS:KV8}910const worker ={11asyncfetch(request:Request, env:Env):Promise<Response>{12const code =newURL(request.url).pathname.slice(1)13// TODO: DELETE removes the key; GET redirects to it14returnnewResponse('Not found',{status:404})15}16}
Cloudflare Workers72 / 194
Module 8 · Bindings and Workers KV
JSON values and expiry
Values are stored as text (or bytes). To keep more than a URL, store JSON and ask get to parse it. A key can also expire: expirationTtl is a lifetime in seconds (at least 60), expiration an exact moment in seconds since 1970. Expired keys disappear by themselves.
A link is saved with expirationTtl: 3600. Someone visits it two hours later. What does get return?
A link is saved with expirationTtl: 3600. Someone visits it two hours later. What does get return?
Cloudflare Workers73 / 194
Module 8 · Bindings and Workers KV
KV is eventually consistent
KV keeps copies of values at many locations. A write goes to a central store first, and other locations keep serving their copy for a while. Changes can take 60 seconds or more to be seen everywhere. This is called eventual consistency: every location will agree, but not instantly. KV also allows only about one write per second to the same key.
QuizInteractive · disabled in the PDF
You change where soup points. Seconds later, a friend on another continent opens the short link. Where do they land?
You change where soup points. Seconds later, a friend on another continent opens the short link. Where do they land?
cacheTtl.get(key, { cacheTtl: 300 }) lets a location keep a value longer (at least 30 seconds), which makes hot keys faster still and changes slower to appear. Tune it per key type: long for links, short for anything you edit often.
Cloudflare Workers74 / 194
Module 8 · Bindings and Workers KV
Listing keys, a page at a time
list returns key names (not values), in alphabetical order, up to 1,000 per call. If there are more, list_complete is false and cursor is a bookmark to pass to the next call. A common trick is a prefix: name keys link:soup, link:docs, and list only link:.
A loop that keeps going until every page has been read.
Cloudflare Workers75 / 194
Module 8 · Bindings and Workers KV
Creating links through the API
Now POST /api/links can write to KV. The Worker checks the token, reads the body, refuses a code that is already taken (409 Conflict), saves the link and answers 201 Created.
Find the bugInteractive · disabled in the PDF
Sometimes a link reported as created is not there afterwards. Which line?
TypeScript
Check, then write is not atomic. Two requests for the same new code can both see “not taken” and both write. KV has no way to say “write only if missing”. When that matters, use D1’s unique constraints or a Durable Object.
Cloudflare Workers76 / 194
Module 8 · Bindings and Workers KV
Try it: create a link
The route checks the token and reads { code, url } from the body, as in earlier modules, then hands the link to createLink. The tests give it a stand-in for KV with the real get and put shapes.
Code challengeInteractive · disabled in the PDF
Finish 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 }.
TypeScript
1// The part of KVNamespace this code uses2interfaceKV{3get(key:string):Promise<string|null>4put(5 key:string,6 value:string,7 options?:{ expirationTtl?:number}8):Promise<void>9}10typeNewLink={ code:string; url:string}1112asyncfunctioncreateLink(link:NewLink, env:{ LINKS:KV}){13// TODO: 409 if taken; else save for 30 days and answer 20114returnnewResponse('Not done yet',{status:501})15}
Cloudflare Workers77 / 194
Module 8 · Bindings and Workers KV
Local and remote data
wrangler dev gives your Worker a local KV, saved in the project’s .wrangler/state folder, separate from the real one. Wrangler’s kv key commands can write to either: say which with --local or --remote.
A \ at the end of a line continues the command on the next.
Try itInteractive · disabled in the PDF
Cloudflare Workers78 / 194
Module 8 · Bindings and Workers KV
Recap: bindings and KV
A binding is declared in config and appears on env: no addresses or keys in code.
KV stores values by key, fast to read everywhere, made for read-heavy data.
get returns null for a missing key; put and delete must be awaited.
Store JSON with JSON.stringify and read it with get(key, 'json').
expirationTtl makes keys expire; list pages through keys with a cursor.
KV is eventually consistent: changes take up to a minute or more to spread.
FlashcardsInteractive · disabled in the PDF
Binding
A named connection to a resource, declared in config, found on env.
KV namespace
One key-value store, bound by its id.
Eventual consistency
Copies agree in the end, but a change is not seen everywhere at once.
expirationTtl
Seconds until a KV key deletes itself (at least 60).
Cursor
A bookmark for the next page of a list.
409
Conflict: the request clashes with what exists, such as a taken code.
.wrangler/state
Where wrangler dev keeps local copies of storage.
Module 9: D1: a SQL database
Cloudflare Workers79 / 194
Module 9 · D1: a SQL database
A database keeps data in tables
A relational database keeps data in tables, like spreadsheets with strict rules. Each column has a name and a type; each row is one record. You talk to it in SQL (Structured Query Language), a language for asking and changing data: “give me the rows where…”, “add this row”.
code (text)
day (text)
clicks (integer)
soup
2026-11-09
41
soup
2026-11-10
12
docs
2026-11-10
7
SQL
1SELECTday, clicks FROM daily_clicks2WHERE code ='soup'3ORDER BYdayDESC;
Read it aloud: select these columns from that table, where…, sorted by….
Cloudflare Workers80 / 194
Module 9 · D1: a SQL database
D1 is SQLite, run by Cloudflare
D1 is a database built on SQLite, a small and very widely used SQL database. Cloudflare runs it for you; your Worker reaches it through a binding. Writes go to one primary copy, so a write is seen by the next query: no waiting for copies to agree, unlike KV.
KV
Look up by key only
Fastest reads, everywhere
Changes spread within a minute or so
One write per second per key
D1
Filter, sort, count, join tables
A query travels to the database
A write is seen by the next query
Many writes per second, with transactions
QuizInteractive · disabled in the PDF
The shortener wants “clicks per day for each link, for the last 30 days”. Where should that live?
The shortener wants “clicks per day for each link, for the last 30 days”. Where should that live?
Cloudflare Workers81 / 194
Module 9 · D1: a SQL database
Create a database and bind it
Terminal
1npxwranglerd1createshortener2# prints a database_id and the lines for the config
Fill the blanksInteractive · disabled in the PDF
Bind the database as env.DB.
JSON with comments
1"": [{2"":"DB",3"database_name":"shortener",4"database_id":"<printed by wrangler d1 create>",5"migrations_dir":"migrations"6}]
Cloudflare Workers82 / 194
Module 9 · D1: a SQL database
Migrations change the database step by step
A migration is a numbered SQL file that changes the database’s shape: create a table, add a column. Wrangler records which ones a database has had, and applies only the new ones, in order. The same files build your local database and the real one, so they always match.
migrations/0001_daily_clicks.sql
1CREATETABLEdaily_clicks (2 code TEXTNOT NULL,3dayTEXTNOT NULL, -- '2026-11-10'4 clicks INTEGERNOT NULLDEFAULT0,5PRIMARY KEY (code, day) -- one row per link per day6);
1const stmt = env.DB.prepare(2'SELECT day, clicks FROM daily_clicks WHERE code = ?1'3).bind(code)45const{ results }=await stmt.all<{ day:string; clicks:number}>()6const row =await stmt.first<{ day:string; clicks:number}>()7const total =await env.DB8.prepare('SELECT SUM(clicks) AS n FROM daily_clicks WHERE code = ?')9.bind(code)10.first<number>('n')
Lines 1-3:prepare takes SQL with placeholders (?1, or ?); bind fills them, in order. Nothing has run yet.
Lines 5:all() runs it and gives every row in results.
Lines 6:first() gives the first row, or null if there is none.
Lines 7-10:first('n') gives just one column of the first row.
For statements that change data, use run(): its result has meta.changes, the number of rows affected.
Cloudflare Workers84 / 194
Module 9 · D1: a SQL database
Never build SQL from strings
If you paste user input into SQL text, a visitor can type SQL of their own, and the database will run it. This attack is SQL injection. With bind, values travel separately from the SQL and are never read as SQL.
Injectable
env.DB.prepare(`SELECT * FROM daily_clicks WHERE code = '${code}'`).all()// code = "x' OR '1'='1" returns every row
Bound
env.DB.prepare('SELECT * FROM daily_clicks WHERE code = ?').bind(code).all()
Find the bugInteractive · disabled in the PDF
Which line lets a visitor change the query?
TypeScript
Cloudflare Workers85 / 194
Module 9 · D1: a SQL database
Count clicks in one statement
Reading the count, adding one in JavaScript and writing it back breaks when two clicks arrive together: both read 41, both write 42. Let the database do it in one atomic statement (one that happens all at once or not at all). ON CONFLICT … DO UPDATE inserts the row, or adds one if it is already there.
TypeScript
1await env.DB.prepare(`2 INSERT INTO daily_clicks (code, day, clicks) VALUES (?, ?, 1)3 ON CONFLICT (code, day) DO UPDATE SET clicks = clicks + 14`).bind(code,newDate().toISOString().slice(0,10)).run()
toISOString().slice(0, 10) gives today’s date in UTC, like 2026-11-10.
QuizInteractive · disabled in the PDF
Two 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?
Two 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?
Cloudflare Workers86 / 194
Module 9 · D1: a SQL database
Batches run as one transaction
env.DB.batch([...]) sends several statements in one trip and runs them as a transaction: if one fails, none of them take effect. It is also faster than awaiting statements one at a time, because each await is a round trip to the database.
TypeScript
1const[, totals]=await env.DB.batch([2 env.DB.prepare('DELETE FROM daily_clicks WHERE code = ?')3.bind(oldCode),4 env.DB.prepare('SELECT COUNT(*) AS n FROM daily_clicks')5])6totals.results // [{ n: 1204 }]
Migrations while the old code runs. A deploy briefly runs the old and the new code side by side. Change a schema in safe steps: add a column (nullable or with a default), deploy code that writes it, then deploy code that reads it. Drop or rename only once nothing uses the old shape.
Cloudflare Workers87 / 194
Module 9 · D1: a SQL database
Looking inside a database
Terminal
1npxwranglerd1executeshortener--local\2--command"SELECT * FROM daily_clicks LIMIT 5"3npxwranglerd1migrationslistshortener--remote
Always say --local or --remote, so you know which data you are touching.
Try itInteractive · disabled in the PDF
Cloudflare Workers88 / 194
Module 9 · D1: a SQL database
Recap: D1
Tables have columns and rows; SQL asks and changes them.
D1 is SQLite run by Cloudflare: a write is seen by the next query.
Migrations are numbered SQL files, applied locally first, then with --remote.
prepare(sql).bind(values), then all, first or run.
Never paste input into SQL: bind it.
Let the database update counts atomically; batch runs statements as one transaction.
FlashcardsInteractive · disabled in the PDF
SQL
The language for querying and changing relational databases.
Migration
A numbered SQL file that changes a database’s shape, applied once.
Primary key
The column(s) that identify a row uniquely.
SQL injection
Input pasted into SQL text and run as SQL. Prevented by bind.
Atomic
Happens all at once or not at all; nothing can slip in between.
Transaction
Several statements that succeed or fail together.
meta.changes
How many rows a run() affected.
Module 10: Deploying, environments and custom domains
Cloudflare Workers89 / 194
Module 10 · Deploying, environments and custom domains
Every upload is a version
Each time you deploy, Cloudflare stores a version: your code plus its config (bindings, vars, compatibility date), never changed afterwards. A deployment says which version receives traffic. wrangler deploy does both at once: it makes a version and sends it 100% of the traffic.
Terminal
1npxwranglerdeploymentslist# recent deployments, newest last2npxwranglerversionslist# recent versions and their ids3npxwranglerrollback# back to the previous version
Version
An immutable snapshot of a Worker’s code and config.
Deployment
Which version (or versions) gets traffic, and in what share.
Rollback
Sending traffic back to an earlier version.
Cloudflare Workers90 / 194
Module 10 · Deploying, environments and custom domains
Rollback undoes code, not data
A rollback is fast because the old version still exists: Cloudflare only switches traffic. But data is not part of a version. Rows written to D1, keys in KV and files in R2 stay as the new version left them, and a migration already applied stays applied.
QuizInteractive · disabled in the PDF
Version 8 added a column with a migration and started writing to it. You roll back to version 7. What is true?
Version 8 added a column with a migration and started writing to it. You roll back to version 7. What is true?
Cloudflare Workers91 / 194
Module 10 · Deploying, environments and custom domains
Environments: a staging copy from the same file
You want somewhere to try changes that is not the real site. An environment is a block under env in wrangler.jsonc that deploys as a separate Worker (named link-shortener-staging here) with --env staging. Watch out: bindings and vars are not inherited. An environment that does not list them has none.
If the staging block had only its vars, what would env.LINKS be in the staging Worker?
If the staging block had only its vars, what would env.LINKS be in the staging Worker?
Cloudflare Workers92 / 194
Module 10 · Deploying, environments and custom domains
Your own domain
To use an address like go.example.com, the domain must be on your Cloudflare account. A domain on Cloudflare is called a zone. A custom domain makes the Worker the whole site at that hostname: Cloudflare creates the DNS record and the certificate (what makes https work) for you.
JSON with comments
1"routes": [2{"pattern":"go.example.com","custom_domain":true}3],4"workers_dev": false// no *.workers.dev address any more
Zone
A domain whose DNS is managed by Cloudflare.
Certificate
The file that proves a site’s identity, so browsers can use https.
Custom domain
A hostname where the Worker is the origin: it answers everything.
Cloudflare Workers93 / 194
Module 10 · Deploying, environments and custom domains
Routes put a Worker in front of part of a site
A route runs the Worker only for matching addresses on a zone, in front of an existing website (its origin). Your Worker can answer by itself, or pass the request on with fetch(request).
The Worker handles some paths of a site that already exists
"workers_dev": true (the default when there are no routes)
Trying things on *.workers.dev
QuizInteractive · disabled in the PDF
A bakery has a website at bakery.example on another server, and wants bakery.example/l/* to be short links. Which fits?
A bakery has a website at bakery.example on another server, and wants bakery.example/l/* to be short links. Which fits?
Cloudflare Workers94 / 194
Module 10 · Deploying, environments and custom domains
Gradual deployments: a little traffic first
wrangler versions upload makes a version without sending it traffic, and prints a preview URL where you can try it. wrangler versions deploy then splits traffic, for example 10% to the new version, and you raise the share once errors stay flat.
Terminal
1npxwranglerversionsupload--message"Count clicks per country"2npxwranglerversionsdeploy<old-id>@90%<new-id>@10%3# watch the logs; then4npxwranglerversionsdeploy<new-id>@100%
Two versions at once. During a split, one visitor can hit the new version and then the old one. Both must understand the same data: the same tables, the same KV value format, the same queue messages.
Put in orderInteractive · disabled in the PDF
Put a careful release in order.
1.Watch errors, then move to 100%
2.wrangler versions upload
3.Apply any additive migration with --remote
4.Try the preview URL
5.Deploy the new version at 10%
Cloudflare Workers95 / 194
Module 10 · Deploying, environments and custom domains
Deploying from CI, without your laptop
CI (continuous integration) is a service that runs commands for you when code is pushed, such as tests and deploys. Cloudflare’s Workers Builds can connect a Git repository from the dashboard. Elsewhere, give the CI service a Cloudflare API token (a key with limited permissions) in the CLOUDFLARE_API_TOKEN environment variable, stored in the CI’s own secret settings.
Terminal
1# what a CI job runs, with CLOUDFLARE_API_TOKEN set as a CI secret2npmci# install exactly what package-lock.json lists3npmtest4npxwranglerdeploy
Tip. Give a CI token only the permissions it needs (editing Workers on one account), so a leaked token can do little.
Cloudflare Workers96 / 194
Module 10 · Deploying, environments and custom domains
Recap: deploying
Each upload is an immutable version; a deployment picks which versions get traffic.
wrangler rollback switches code back; data and migrations stay.
Environments deploy separate Workers; bindings and vars are not inherited.
Custom domains make the Worker the whole hostname; routes cover paths on a zone.
versions upload then versions deploy with percentages ships gradually.
CI deploys with a narrowly scoped API token kept in the CI’s secrets.
FlashcardsInteractive · disabled in the PDF
Version
An immutable snapshot of a Worker’s code and config.
Environment
A block under env in the config that deploys as its own Worker.
Zone
A domain managed by Cloudflare.
Route
A pattern on a zone that sends matching requests to a Worker.
Origin
The server a site lives on, behind Cloudflare.
Gradual deployment
Splitting traffic between versions, raising the new one’s share step by step.
CI
A service that runs tests and deploys when code is pushed.
Module 11: R2: storing files
Cloudflare Workers97 / 194
Module 11 · R2: storing files
R2 stores files, called objects
KV holds small values and D1 holds rows. Images, PDFs and videos are files, and R2 is Cloudflare’s storage for files. You keep objects (a file plus a little information about it) in a bucket, each under a key that looks like a path: qr/soup.png. R2 charges for storage and operations, but not for the data people download (no egress fees).
Object storage
Storage for whole files, each saved and fetched by key.
Bucket
A container of objects in R2, bound to Workers by name.
Object
A file’s bytes plus metadata such as its type and size.
Metadata
Data about data: a file’s type, size, date.
Note. R2 also speaks the S3 API, the de facto standard for object storage, so many existing tools can read and write it. Inside a Worker you use the binding instead.
Cloudflare Workers98 / 194
Module 11 · R2: storing files
Create a bucket and bind it
Terminal
1npxwranglerr2bucketcreateshortener-files
Fill the blanksInteractive · disabled in the PDF
Bind the bucket as env.FILES.
JSON with comments
1"": [2{"binding":"","":"shortener-files"}3]
Cloudflare Workers99 / 194
Module 11 · R2: storing files
Uploading: put a stream
The owner uploads a QR code image for a link with PUT /api/links/soup/qr. The request body is a stream, and put accepts a stream directly: the bytes flow from the visitor to R2 without the Worker holding the whole file in memory. Save the file’s type in httpMetadata, so it can be served correctly later.
TypeScript
1const type = request.headers.get('content-type')??''2if(!['image/png','image/svg+xml'].includes(type)){3returnnewResponse('PNG or SVG only',{status:415})4}5await env.FILES.put(`qr/${code}`, request.body,{6httpMetadata:{contentType: type }7})8returnnewResponse(null,{status:204})
415 Unsupported Media Type: the body is a kind the server does not accept.
Lines 1-4:get returns the object, or null if no object has that key.
Lines 5-6:writeHttpMetadata copies what was saved with httpMetadata, such as Content-Type, onto the headers.
Lines 7: The ETag is a fingerprint of the file’s content; browsers use it to ask “has this changed?”.
Lines 8: Let browsers keep the image for a day (86,400 seconds).
Lines 9:object.body is a stream: the response sends it on as it arrives.
QuizInteractive · disabled in the PDF
An image was uploaded without httpMetadata, and the Worker serves it as above. What will a browser most likely do when opening the link?
An image was uploaded without httpMetadata, and the Worker serves it as above. What will a browser most likely do when opening the link?
Cloudflare Workers101 / 194
Module 11 · R2: storing files
Stream, do not buffer
Each isolate has a limited amount of memory (128 MB). await object.arrayBuffer() loads the whole file into it; a large video can run the Worker out of memory, and even small files waste time waiting for the last byte before sending the first. Passing object.body straight into a Response sends bytes as they arrive.
Why does streaming a 1 GB file from R2 work when loading it with arrayBuffer() does not?
Why does streaming a 1 GB file from R2 work when loading it with arrayBuffer() does not?
Cloudflare Workers102 / 194
Module 11 · R2: storing files
head, list and delete
TypeScript
1const info =await env.FILES.head('qr/soup')// metadata, no body2info?.size // bytes34const page =await env.FILES.list({prefix:'qr/',limit:100})5page.objects.map((o)=> o.key)// ["qr/docs", …]6page.truncated // more to fetch?78await env.FILES.delete('qr/soup')
Like KV’s list, a truncated listing gives a cursor for the next page.
Find the bugInteractive · disabled in the PDF
Deleting a link should also delete its QR image, but images pile up. Which line?
TypeScript
Cloudflare Workers103 / 194
Module 11 · R2: storing files
Conditional and partial reads
Browsers send headers like If-None-Match (“I have the version with this ETag”) and Range (“only bytes 0 to 999”, used by video players). get can take the request’s headers and apply both: it then returns only the part asked for, or no body when the browser’s copy is still current.
When the condition fails, R2 returns the object’s metadata without a body.
Ranges answer 206. A full range-aware server also answers 206 Partial Content with a Content-Range header built from object.range. If you do not need video seeking, skip ranges and keep the ETag check: it already saves most repeat downloads.
Cloudflare Workers104 / 194
Module 11 · R2: storing files
Who may read the bucket
A bucket is private by default: only bound Workers (and API tokens you create) can reach it. Serving through a Worker lets you check permissions, count downloads and set headers. For files anyone may see, you can instead connect a custom domain to the bucket in the dashboard, and Cloudflare serves it without your code running.
How
Good for
Through a Worker
Checks, counting, changing headers
A public bucket on a custom domain
Public files, with no code in the way
Signed URLs through the S3 API
Giving one person time-limited access
Try itInteractive · disabled in the PDF
Cloudflare Workers105 / 194
Module 11 · R2: storing files
Recap: R2
R2 stores files as objects in buckets, by key; downloads are not charged.
put(key, stream, { httpMetadata }) uploads and records the type.
get returns an object or null; writeHttpMetadata and httpEtag set headers.
Stream bodies through; do not load large files into memory.
head, list (with truncated and cursor) and delete manage objects.
onlyIf and range let R2 answer conditional and partial requests.
FlashcardsInteractive · disabled in the PDF
Bucket
A container of objects in R2, bound by name.
Object
A file and its metadata, stored under a key.
Egress
Data sent out of a storage service; R2 does not charge for it.
ETag
A fingerprint of a file’s content, used to check whether it changed.
304
Not Modified: the browser’s copy is still current, so no body is sent.
415
Unsupported Media Type: the server does not accept that kind of body.
Buffering
Loading a whole body into memory before using it; the opposite of streaming.
Module 12: Background work: waitUntil and Cron Triggers
Cloudflare Workers106 / 194
Module 12 · Background work: waitUntil and Cron Triggers
The visitor should not wait for bookkeeping
Every redirect now counts a click in D1 before answering. The visitor does not care about the count: they want the page. Every millisecond spent writing it delays their redirect. The fix is to send the response first and finish the write afterwards.
The response leaves first; the count follows.
Cloudflare Workers107 / 194
Module 12 · Background work: waitUntil and Cron Triggers
ctx.waitUntil keeps the Worker alive a little longer
Normally, once fetch returns, the runtime may stop anything still running for that request. ctx.waitUntil(promise) asks it to keep going until the promise settles, for up to 30 seconds after the response is sent. Work handed to it does not delay the response.
Module 12 · Background work: waitUntil and Cron Triggers
Try it: count without waiting
ctx is an object with a waitUntil(promise) method. The tests hand your Worker a fake one that remembers the promise it was given, then wait for that promise themselves, as Cloudflare’s test tools do.
Code challengeInteractive · disabled in the PDF
The redirect should go out at once, and the click be counted in the background. Hand countClick(code) to ctx.waitUntil, without awaiting it.
TypeScript
1interfaceCtx{2waitUntil(promise:Promise<unknown>):void3}4const LINKS:Record<string,string>={5soup:'https://example.com/soup'6}7const clicks =newMap<string,number>()89asyncfunctioncountClick(code:string){10awaitnewPromise((done)=>setTimeout(done,20))// a slow write11 clicks.set(code,(clicks.get(code)??0)+1)12}1314const worker ={15asyncfetch(request:Request, env:unknown, ctx:Ctx){16const code =newURL(request.url).pathname.slice(1)17const target = LINKS[code]18if(!target)returnnewResponse('Not found',{status:404})19// TODO: count the click without delaying the redirect20return Response.redirect(target,302)21}22}
Cloudflare Workers109 / 194
Module 12 · Background work: waitUntil and Cron Triggers
What waitUntil does not promise
No retries. If the work throws, it is logged and gone.
No error page. The visitor already has their response, so failures are silent unless you log them.
A time limit. Work still running 30 seconds after the response is cancelled.
Best effort. A crash or a restart of the machine can lose it.
QuizInteractive · disabled in the PDF
countClick inside ctx.waitUntil throws because D1 is briefly unreachable. What happens?
countClick inside ctx.waitUntil throws because D1 is briefly unreachable. What happens?
Cloudflare Workers110 / 194
Module 12 · Background work: waitUntil and Cron Triggers
Cron: code on a timetable
Cron is an old Unix name for running jobs on a schedule. A cron expression has five fields: minute, hour, day of the month, month, day of the week. * means “every”. Cloudflare’s Cron Triggers read these expressions in UTC (the world’s reference time zone, not your local time). Days of the week go from 1 (Sunday) to 7 (Saturday), unlike many other cron systems, so names such as MON are clearer.
Expression
Runs
*/5 * * * *
Every five minutes
0 3 * * *
Every day at 03:00 UTC
30 2 * * MON
Mondays at 02:30 UTC (2 would also mean Monday)
0 0 1 * *
At midnight UTC on the first of each month
QuizInteractive · disabled in the PDF
When does 15 4 * * * run?
When does 15 4 * * * run?
Cloudflare Workers111 / 194
Module 12 · Background work: waitUntil and Cron Triggers
The scheduled handler
wrangler.jsonc
1"triggers": {"crons":["17 3 * * *"]}
WalkthroughInteractive · disabled in the PDF
src/index.ts
1exportdefault{2asyncfetch(request, env, ctx){3returnroute(request, env, ctx)4},56asyncscheduled(controller, env, ctx){7const cutoff =newDate(Date.now()-90*86_400_000)8.toISOString().slice(0,10)9const{ meta }=await env.DB10.prepare('DELETE FROM daily_clicks WHERE day < ?')11.bind(cutoff)12.run()13 console.log({job:'cleanup',deleted: meta.changes })14}15}satisfiesExportedHandler<Env>
Lines 2-4: The same Worker still answers web requests.
Lines 6:scheduled is a second handler. It gets a controller (which schedule fired, and when) instead of a request.
Lines 7-8: The date 90 days ago. 86_400_000 is the milliseconds in a day; underscores only make the number readable.
Lines 9-12: Delete old daily counts in one statement.
Lines 13: Log what happened, as an object, so it can be searched later.
Off the hour. Countless jobs everywhere run at minute 0, so the services you call are busiest then. Picking an odd minute like 17 costs nothing.
Cloudflare Workers112 / 194
Module 12 · Background work: waitUntil and Cron Triggers
Several schedules, one handler
List several expressions, and the same scheduled handler runs for each. controller.cron says which one fired; controller.scheduledTime is when it was due, in milliseconds.
Fill the blanksInteractive · disabled in the PDF
Run the clean-up nightly and a summary every Monday.
Watch out. A schedule changed in the config takes effect only after you deploy, and can take several minutes (up to 15) to reach every location. Do not count on a run that throws being run again: if a job must not be missed, make it safe to run again and let the next run catch up.
Cloudflare Workers113 / 194
Module 12 · Background work: waitUntil and Cron Triggers
Running a cron on your computer
Terminal
1npxwranglerdev--test-scheduled2# in another terminal:3curl"http://localhost:8787/__scheduled?cron=17+3+*+*+*"
Spaces in the cron are written as + in the address. Without the flag, wrangler dev also answers /cdn-cgi/local/scheduled?cron=…, the address it suggests when the config has crons.
Try itInteractive · disabled in the PDF
Cloudflare Workers114 / 194
Module 12 · Background work: waitUntil and Cron Triggers
Which background tool for which job
Tool
Starts
Retries
Use for
ctx.waitUntil
During a request
No
Small follow-ups you can afford to lose
Cron Trigger
On a schedule
No guarantee
Clean-ups, reports, refreshing caches
Queue
When a message is sent
Yes
Work that must happen, maybe in bulk
Workflow
When code starts one
Per step
Long jobs of several steps, with sleeps
QuizInteractive · disabled in the PDF
After a link is created, the owner must get a confirmation email even if the email service is down for ten minutes. Which tool?
After a link is created, the owner must get a confirmation email even if the email service is down for ten minutes. Which tool?
Cloudflare Workers115 / 194
Module 12 · Background work: waitUntil and Cron Triggers
Recap: background work
ctx.waitUntil(promise) finishes work after the response, for up to 30 seconds.
A floating promise may be cancelled; waitUntil work is not retried.
Cron expressions have five fields and run in UTC.
The scheduled(controller, env, ctx) handler runs for each triggers.crons entry.
controller.cron tells several schedules apart.
Test locally with wrangler dev --test-scheduled and /__scheduled?cron=….
FlashcardsInteractive · disabled in the PDF
ctx.waitUntil
Keeps the Worker running a promise after the response, up to 30 seconds.
Floating promise
A promise nobody awaits or hands to waitUntil; it may be cut off.
Cron expression
Five fields: minute, hour, day of month, month, day of week.
UTC
Coordinated Universal Time: the reference time zone cron triggers use.
scheduled handler
The handler the runtime calls when a Cron Trigger fires.
controller.cron
The expression that fired this scheduled run.
Module 13: Queues: work that must happen, later
Cloudflare Workers116 / 194
Module 13 · Queues: work that must happen, later
A queue holds messages until someone handles them
A queue is a waiting line for messages. A producer puts messages in; a consumer takes them out and does the work. The producer does not wait for the work, and if the consumer fails, the message stays and is tried again. The shortener will send one message per click, and a consumer will write them to D1 in bulk.
Redirects stay fast; the counting happens in batches.
Producer
Code that sends messages to a queue.
Consumer
Code that receives messages and does the work.
Message
One piece of data in a queue, such as one click.
Batch
Several messages delivered to the consumer together.
Cloudflare Workers117 / 194
Module 13 · Queues: work that must happen, later
Create the queue and bind both sides
Terminal
1npxwranglerqueuescreateclicks
Fill the blanksInteractive · disabled in the PDF
Let this Worker send to clicks as env.CLICKS, and consume it in batches of up to 100.
1typeClick={ code:string; day:string; country:string}23ctx.waitUntil(env.CLICKS.send({4 code,5 day:newDate().toISOString().slice(0, 10),6 country:String(request.cf?.country??'unknown')7}satisfiesClick))89// several at once:10awaitenv.CLICKS.sendBatch(clicks.map((body)=>({ body })))
A message can be any value that can be copied (objects, strings, numbers), up to 128 KB.
QuizInteractive · disabled in the PDF
A message is handled seconds or minutes after it is sent. What belongs in it?
A message is handled seconds or minutes after it is sent. What belongs in it?
Cloudflare Workers119 / 194
Module 13 · Queues: work that must happen, later
The queue handler receives batches
WalkthroughInteractive · disabled in the PDF
TypeScript
1exportdefault{2asyncfetch(request, env, ctx){/* … */},34asyncqueue(batch, env, ctx){5const counts =newMap<string,number>()6for(const message of batch.messages){7const key =`${message.body.code}|${message.body.day}`8 counts.set(key,(counts.get(key)??0)+1)9}10await env.DB.batch([...counts].map(([key, n])=>{11const[code, day]= key.split('|')12return env.DB.prepare(`13 INSERT INTO daily_clicks (code, day, clicks) VALUES (?1, ?2, ?3)14 ON CONFLICT (code, day) DO UPDATE SET clicks = clicks + ?315 `).bind(code, day, n)16}))17}18}satisfiesExportedHandler<Env,Click>
Lines 4:queue is the consumer’s handler. batch.messages holds up to max_batch_size messages.
Lines 5-9: Add up clicks per link and day. A Map is a key-value collection in memory.
Lines 10-16: One DB.batch writes every total in a single round trip. ?3 reuses the third bound value.
Lines 18: The second type argument says what message bodies look like.
Cloudflare Workers120 / 194
Module 13 · Queues: work that must happen, later
Success, failure and retries
If the queue handler finishes without throwing, the whole batch counts as handled. If it throws, every message in it is delivered again later. To decide message by message, call message.ack() (done) or message.retry() (try again, optionally with delaySeconds).
TypeScript
1for(const message of batch.messages){2try{3awaitsendEmail(env, message.body)4 message.ack()5}catch{6 message.retry({delaySeconds:60})7}8}
QuizInteractive · disabled in the PDF
A consumer handles a batch of 10 without calling ack(), and throws on message 3. What is delivered again?
A consumer handles a batch of 10 without calling ack(), and throws on message 3. What is delivered again?
Cloudflare Workers121 / 194
Module 13 · Queues: work that must happen, later
Try it: ack or retry each message
Here the loop over batch.messages lives in a function of its own, given the messages and the function that sends one email. The Message type below is the part of the real one this code uses; the tests make messages whose ack and retry are vi.fn(), mock functions that remember how they were called.
Code challengeInteractive · disabled in the PDF
Finish 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.
TypeScript
1interfaceMessage<Body>{2 body:Body3ack():void4retry(options?:{ delaySeconds?:number}):void5}6typeEmail={ to:string}78asyncfunctionhandleBatch(9 messages:Message<Email>[],10send:(email:Email)=>Promise<void>11){12for(const message of messages){13// TODO: send it; ack on success, retry in 60 s on failure14}15}
Cloudflare Workers122 / 194
Module 13 · Queues: work that must happen, later
At least once means maybe twice
Queues deliver every message at least once. After a retry, or rarely even without one, a message can arrive twice. Make the work idempotent: doing it twice has the same effect as doing it once. Sending an email twice is not idempotent; “mark the email as sent, if it is not yet” is.
Twice means two emails
awaitsendEmail(env, message.body)message.ack()
Twice is harmless
const{ meta }=await env.DB.prepare('INSERT OR IGNORE INTO sent (id) VALUES (?)').bind(message.body.emailId).run()if(meta.changes ===1)awaitsendEmail(env, message.body)message.ack()
Expert note. The idempotent version has its own gap: if sendEmail fails after the row is written, the retry skips it. Real systems pick which failure they prefer (an email lost or an email doubled) and design for it. For click counts, an occasional double is fine.
Cloudflare Workers123 / 194
Module 13 · Queues: work that must happen, later
Giving up: the dead letter queue
A message that fails every time would be retried forever. max_retries (3 by default) sets how many retries it gets. After that it is dropped, unless you name a dead letter queue: a second queue where failed messages wait, so you can inspect them and replay them once the bug is fixed.
Put the life of a message that keeps failing in order.
1.It is retried until max_retries is reached
2.It moves to the dead letter queue
3.The queue delivers it in a batch
4.The producer calls send()
5.The consumer throws
Cloudflare Workers124 / 194
Module 13 · Queues: work that must happen, later
Bigger batches, or quicker ones
Larger batches mean fewer consumer runs and fewer database writes, but each message waits longer before it is handled. max_batch_timeout caps that wait. For analytics, waiting a few seconds is fine; for an email the person is waiting for, keep it short.
QuizInteractive · disabled in the PDF
With max_batch_size: 100 and max_batch_timeout: 5, a quiet night brings 3 clicks a minute. When does the consumer run?
With max_batch_size: 100 and max_batch_timeout: 5, a quiet night brings 3 clicks a minute. When does the consumer run?
Cloudflare Workers125 / 194
Module 13 · Queues: work that must happen, later
Recap: queues
Producers send plain data; consumers receive batches in a queue handler.
A batch is sent when full or when max_batch_timeout passes.
Throwing retries the whole batch, except messages already acked.
Delivery is at least once: make handling idempotent.
max_retries and a dead letter queue decide what happens to messages that keep failing.
Batching turns many small writes into a few large ones.
FlashcardsInteractive · disabled in the PDF
Queue
A waiting line of messages between a producer and a consumer.
At-least-once delivery
Every message arrives, but some may arrive more than once.
Idempotent
Doing it twice has the same effect as doing it once.
message.ack()
Marks one message as done, so a later throw does not retry it.
message.retry()
Sends one message back for another try, optionally after a delay.
Dead letter queue
Where messages go after their retries run out.
Module 14: Durable Objects: one place to coordinate
Cloudflare Workers126 / 194
Module 14 · Durable Objects: one place to coordinate
Many copies cannot agree on their own
The owner wants a new kind of link: one that works for the first 100 visitors only. Your Worker runs in hundreds of places at once, and the copies share no memory. KV is eventually consistent, so two locations can both read 99. Something must count in one place, one visitor at a time.
QuizInteractive · disabled in the PDF
Why not keep the count in a variable at the top of the Worker’s file?
Why not keep the count in a variable at the top of the Worker’s file?
Cloudflare Workers127 / 194
Module 14 · Durable Objects: one place to coordinate
One instance per name, in the whole world
A Durable Object is an instance of a class you write. For each name there is exactly one instance on Earth, running in one place, handling its calls one at a time, with private storage on the same machine. Ask for the object named link:vip from Tokyo or Toronto and you reach the same one.
Durable Object
A single, named instance of your class, with its own storage.
Class
A template for objects: their data and their methods.
Stub
A stand-in you get from the binding; calling its methods calls the real object, wherever it is.
RPC
Remote procedure call: calling a method that runs somewhere else as if it were local.
Many Workers, one object per name.
Cloudflare Workers128 / 194
Module 14 · Durable Objects: one place to coordinate
A class whose methods Workers call
WalkthroughInteractive · disabled in the PDF
src/limited-link.ts
1import{ DurableObject }from'cloudflare:workers'23exportclassLimitedLinkextendsDurableObject<Env>{4asyncadmit(max:number):Promise<boolean>{5const used =(awaitthis.ctx.storage.get<number>('used'))??06if(used >= max)returnfalse7awaitthis.ctx.storage.put('used', used +1)8returntrue9}10}
Lines 1:cloudflare:workers is a module built into the runtime.
Lines 3: Your class extends DurableObject, which gives it this.ctx (its storage and more) and this.env.
Lines 4: Any public method can be called by Workers holding a stub.
Lines 5-8: Read the count from the object’s own storage, refuse when the limit is reached, otherwise add one and admit.
Watch out. These migrations are not D1 migrations: they describe classes, not tables. Never delete an old entry from the list; add a new tag for each change.
Cloudflare Workers130 / 194
Module 14 · Durable Objects: one place to coordinate
Calling an object by name
TypeScript
1const stub = env.LIMITED.getByName(`link:${code}`)2// the same as: env.LIMITED.get(env.LIMITED.idFromName(...))34if(!(await stub.admit(100))){5returnnewResponse('This link has reached its limit',{6status:4107})8}9return Response.redirect(target,302)
410 Gone: the thing existed but is no longer available.
QuizInteractive · disabled in the PDF
Two visitors, one in Paris and one in Sydney, open /vip at the same instant. Both Workers call getByName('link:vip'). What do they reach?
Two visitors, one in Paris and one in Sydney, open /vip at the same instant. Both Workers call getByName('link:vip'). What do they reach?
Cloudflare Workers131 / 194
Module 14 · Durable Objects: one place to coordinate
Two ways to store: key-value and SQL
A SQLite-backed object has a private database. Use the key-value methods for simple values, or SQL for rows. Both are on the same machine as the object, so reads take microseconds rather than a network trip.
TypeScript
1// key-value2awaitthis.ctx.storage.put('used',1)3const used =awaitthis.ctx.storage.get<number>('used')45// SQL6this.ctx.storage.sql.exec(7'CREATE TABLE IF NOT EXISTS visits (at INTEGER, country TEXT)'8)9this.ctx.storage.sql.exec(10'INSERT INTO visits VALUES (?, ?)', Date.now(), country11)12const rows =this.ctx.storage.sql13.exec('SELECT country, COUNT(*) AS n FROM visits GROUP BY 1')14.toArray()
sql.exec takes the values after the SQL, and runs at once (no await).
Cloudflare Workers132 / 194
Module 14 · Durable Objects: one place to coordinate
One at a time, until you await something else
While an object waits on its own storage, the runtime holds other calls back (this is called the input gate), so the get then put in admit is safe. Awaiting anything else, like a fetch to another server, lets another call run in between.
Predict the outputInteractive · disabled in the PDF
One place is left. Two calls to admit arrive together. What can happen?
One place is left. Two calls to admit arrive together. What can happen?
Cloudflare Workers133 / 194
Module 14 · Durable Objects: one place to coordinate
Alarms wake an object later
An object can schedule its own future: this.ctx.storage.setAlarm(time) makes the runtime call its alarm() method at that time, even if nothing else wakes it. Alarms are retried if alarm() throws. Objects can also hold WebSocket connections (two-way live connections), for chat, games or live dashboards.
TypeScript
1async admit(max: number){2// … as before; on the first visit, reset in 24 hours:3if((awaitthis.ctx.storage.getAlarm())===null){4awaitthis.ctx.storage.setAlarm(Date.now()+86_400_000)5}6}78async alarm(){9awaitthis.ctx.storage.deleteAll()// the count starts again10}
Cloudflare Workers134 / 194
Module 14 · Durable Objects: one place to coordinate
When to use one, and how to keep it fast
One object handles its calls one at a time, so a single object has a ceiling on how much it can handle per second. Design so each object owns a small piece: one per link, per user, per chat room. Never one object for the whole service.
Use a Durable Object for
Use something else for
Exact counters and limits per thing
Data read everywhere, rarely changed (KV)
Live rooms, games, collaborative documents
Reports across all users (D1)
Per-entity state that changes fast
Files (R2)
QuizInteractive · disabled in the PDF
A team puts every click from every link through one object named global. What will they hit first?
A team puts every click from every link through one object named global. What will they hit first?
Cloudflare Workers135 / 194
Module 14 · Durable Objects: one place to coordinate
Recap: Durable Objects
One instance per name, worldwide, with private storage on the same machine.
Write a class extending DurableObject, export it, bind it, and add a migration.
getByName(name) gives a stub; its methods are RPC calls to the object.
Storage calls hold other calls back; outside I/O lets them interleave.
Alarms call alarm() later; objects can hold WebSockets.
Spread state over many small objects; one object is one line of work.
FlashcardsInteractive · disabled in the PDF
Durable Object
A single named instance of your class, with its own storage.
Stub
The handle from getByName; calling it calls the real object.
new_sqlite_classes
The migration key that creates a class with SQLite storage.
Input gate
The runtime holding other calls back while an object awaits its own storage.
Alarm
A time set with setAlarm at which the runtime calls alarm().
410
Gone: it existed but is no longer available.
Module 15: Static assets and service bindings
Cloudflare Workers136 / 194
Module 15 · Static assets and service bindings
Static assets are served before your code runs
The owner wants a small dashboard page to manage links. Its HTML, CSS and JavaScript never change between visits: they are static assets. Point assets.directory at a folder, and wrangler deploy uploads it. A request that matches a file is answered from Cloudflare’s network without running your Worker, and asset requests are not billed as Worker requests.
none: no file, run the Worker (or a plain 404 without one); 404-page: serve the nearest 404.html; single-page-application: serve index.html
html_handling
How /about, /about/ and /about.html map to files
run_worker_first
true, or a list of path patterns that go to the Worker even when a file matches
binding
Puts the assets on env, so code can serve a file: env.ASSETS.fetch(request)
QuizInteractive · disabled in the PDF
With the config above and public/favicon.ico present, which requests run the Worker’s code?
With the config above and public/favicon.ico present, which requests run the Worker’s code?
Cloudflare Workers138 / 194
Module 15 · Static assets and service bindings
Serving a file from code
The dashboard should be for the owner only. List it in run_worker_first, so your code sees those requests first, checks who is asking, and then hands them to the assets binding.
_headers and _redirects. A _headers file in the assets folder sets headers by path, and _redirects sets simple redirects. They apply only to responses from the asset layer, not to responses your code builds.
Cloudflare Workers139 / 194
Module 15 · Static assets and service bindings
Why split a system into several Workers
As the shortener grows, one Worker does everything: public redirects, an admin API, the queue consumer. Splitting it lets each part deploy on its own, have only the bindings it needs, and be reachable only by who should reach it.
One public Worker; the others have no public address.
Cloudflare Workers140 / 194
Module 15 · Static assets and service bindings
Service bindings: Worker to Worker, privately
A service binding gives one Worker a direct line to another: env.API.fetch(request). The call does not cross the public internet or DNS; it usually runs in the same location, often on the same machine, adding almost no delay. The called Worker needs no public address at all: set workers_dev: false and give it no routes.
Fill the blanksInteractive · disabled in the PDF
Give the public Worker a binding API to the Worker named links-api.
JSON with comments
1"": [2{"binding":"","":"links-api"}3]
TypeScript
1if(pathname.startsWith('/api/')){2return env.API.fetch(request)// passed on untouched3}
Cloudflare Workers141 / 194
Module 15 · Static assets and service bindings
RPC: call methods instead of URLs
Instead of building requests, a Worker can offer methods. Export a class that extends WorkerEntrypoint; callers bind to it with entrypoint and call its methods with plain arguments. Values are copied across: objects, arrays, dates, even streams.
Why use a service binding instead of fetch('https://links-api.<subdomain>.workers.dev/…')?
Why use a service binding instead of fetch('https://links-api.<subdomain>.workers.dev/…')?
Cloudflare Workers142 / 194
Module 15 · Static assets and service bindings
Several Workers on your computer
Locally, run each Worker with wrangler dev in its own terminal. Wrangler keeps a local dev registry, so a service binding finds the other Worker by name, as it would on Cloudflare. You can also start several at once by passing more than one config: npx wrangler dev -c wrangler.jsonc -c ../links-api/wrangler.jsonc.
Find the bugInteractive · disabled in the PDF
The caller gets an error that resolve does not exist. Which line of its config is wrong?
JSON with comments
Cloudflare Workers143 / 194
Module 15 · Static assets and service bindings
Recap: assets and services
assets.directory uploads static files; matching requests skip your code.
run_worker_first sends chosen paths to code first; env.ASSETS.fetch serves a file from code.
_headers and _redirects apply to asset responses only.
Split a system into Workers with only the bindings and addresses each needs.
Service bindings call another Worker privately: env.API.fetch() or RPC methods.
RPC: extend WorkerEntrypoint and bind with entrypoint.
FlashcardsInteractive · disabled in the PDF
Static asset
A file served as is: HTML, CSS, images, scripts.
run_worker_first
Paths that reach the Worker even when a file matches.
Service binding
A private connection from one Worker to another, on env.
WorkerEntrypoint
The base class for a Worker that offers RPC methods.
entrypoint
Which exported class a service binding connects to.
Dev registry
How local wrangler dev sessions find each other by name.
Module 16: Caching and the Cache API
Cloudflare Workers144 / 194
Module 16 · Caching and the Cache API
A cache keeps answers to reuse
A cache is a store of copies of earlier answers. If the same question comes again, the copy is returned without redoing the work. On the web there are caches at several layers, and each one that answers saves every layer below it.
Browser cacheon the visitor’s device
Cloudflare cachein each location
Your Workercomputes the answer
D1, KV, R2, other APIsthe slowest trips
The higher the layer that answers, the faster and cheaper.
Cache hit
The answer was found in the cache.
Cache miss
It was not, so the work had to be done.
Stale
A cached copy older than it is allowed to be.
Cloudflare Workers145 / 194
Module 16 · Caching and the Cache API
Cache-Control says who may keep a copy, and for how long
Directive
Means
max-age=3600
Keep it for 3600 seconds
s-maxage=600
Shared caches (like Cloudflare’s) keep it 600 seconds; overrides max-age for them
public
Any cache may keep it
private
Only the visitor’s own browser may keep it
no-store
Nobody keeps it
QuizInteractive · disabled in the PDF
A Worker returns the owner’s list of links with their click counts. Which header fits?
A Worker returns the owner’s list of links with their click counts. Which header fits?
Cloudflare Workers146 / 194
Module 16 · Caching and the Cache API
A cached redirect is a click you never see
Browsers keep 301 redirects, often with no expiry at all, so the second visit goes straight to the long link without asking your Worker. That is fast, but the click is not counted and you can never change where the link points for that visitor. A 302 with Cache-Control: private, max-age=60 is a deliberate middle ground.
QuizInteractive · disabled in the PDF
A link was answered with 301 last week. Today you point it somewhere else. Where does a returning visitor land?
A link was answered with 301 last week. Today you point it somewhere else. Where does a returning visitor land?
Cloudflare Workers147 / 194
Module 16 · Caching and the Cache API
The Cache API: Cloudflare’s cache from code
caches.default is the cache of the Cloudflare location running your code. You match a request to find a stored response and put a response to store one. The stats page is expensive to build, so cache it for a minute.
WalkthroughInteractive · disabled in the PDF
TypeScript
1asyncfunctionstats(2 request:Request, env:Env, ctx:ExecutionContext3){4const cache = caches.default5const hit =await cache.match(request)6if(hit)return hit78const body =awaitbuildStats(env)// slow: several queries9const response = Response.json(body,{10headers:{'Cache-Control':'public, max-age=60'}11})12 ctx.waitUntil(cache.put(request, response.clone()))13return response14}
Lines 4-6: Look for a stored copy first; on a hit, return it and skip all the work.
Lines 8-11: On a miss, build the response, with a Cache-Control saying how long it may be kept.
Lines 12:clone() makes a second copy of the body stream (one for the cache, one for the visitor). Storing happens in the background.
Cloudflare Workers148 / 194
Module 16 · Caching and the Cache API
What the Cache API will and will not do
Per location. A put in Frankfurt is not seen in Madrid; each location fills its own copy.
GET only. Only GET requests can be keys.
Headers rule. A response marked no-store or private is not kept; max-age or s-maxage sets how long.
No cookies. A response with a Set-Cookie header is not stored: remove the header before put.
QuizInteractive · disabled in the PDF
You 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?
You 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?
Cloudflare Workers149 / 194
Module 16 · Caching and the Cache API
Choose the cache key on purpose
The key is the request’s URL (plus a few headers). /stats?utm_source=mail and /stats?utm_source=chat are different keys, so marketing tags split the cache into many cold copies. Build a normalized key: the same URL for requests that should share an answer.
TypeScript
1const url =newURL(request.url)2const key =newRequest(`${url.origin}${url.pathname}`,{3method:'GET'4})5const hit =await caches.default.match(key)
A key without the query string: every /stats?… shares one entry.
Find the bugInteractive · disabled in the PDF
Per-link stats live at /api/links/:code/stats?days=7 or ?days=30. Visitors asking for 30 days sometimes get 7. Which line?
TypeScript
Cloudflare Workers150 / 194
Module 16 · Caching and the Cache API
Try it: normalize a cache key
Code challengeInteractive · disabled in the PDF
Write cacheKey(request). It returns a new GETRequest for the same origin and path, keeping the days query parameter when there is one and dropping every other (such as utm_source).
TypeScript
1functioncacheKey(request:Request):Request{2// TODO: same origin and path, only ?days= kept, method GET3return request4}
Cloudflare Workers151 / 194
Module 16 · Caching and the Cache API
Caching what you fetch
When a Worker on a zone calls fetch, the response can be cached too. The cf option on fetch is Cloudflare’s own: cacheTtl keeps the answer for that many seconds, and cacheEverything caches it even when it is HTML, which Cloudflare does not cache by default.
TypeScript
1// a slow public API, cached for five minutes at this location2const res =awaitfetch('https://api.example.com/exchange-rates',{3cf:{cacheTtl:300,cacheEverything:true}4})
Purging.cache.delete(key) removes an entry only in the current location. To change cached content everywhere at once, either purge it through Cloudflare’s API or dashboard, or put a version in the key (/stats?v=42) and bump the version: old entries simply stop being asked for.
Cloudflare Workers152 / 194
Module 16 · Caching and the Cache API
What not to cache
Cache it
The same answer for everyone: public pages, images, totals
Expensive to build, cheap to be a little stale
Answers from slow outside APIs
Do not
Anything tied to who is asking (sessions, accounts)
Answers to POST, PUT, DELETE
Values that must be exact now, such as a limit about to be reached
QuizInteractive · disabled in the PDF
Which of these is safe to cache in a shared cache for a minute? Pick all that apply.
Which of these is safe to cache in a shared cache for a minute? Pick all that apply.
Cloudflare Workers153 / 194
Module 16 · Caching and the Cache API
Recap: caching
Caches at the browser and at Cloudflare save every layer below them.
Cache-Control decides who may keep a copy and for how long.
301s are cached by browsers; editable links should use 302.
caches.default is per location, stores only GET requests, and skips responses that set cookies.
Normalize the cache key: keep what changes the answer, drop what does not.
Never share-cache personal data or responses to writes.
FlashcardsInteractive · disabled in the PDF
Cache hit / miss
Found in the cache, or not and the work had to be done.
s-maxage
How long shared caches may keep a response.
private
Only the visitor’s own browser may cache it.
caches.default
The Cache API’s cache for the current Cloudflare location.
Cache key
What identifies a cached entry: the request URL, normally.
Purge
Removing entries from caches everywhere, through Cloudflare’s API.
response.clone()
A second copy of a response, so its body can be read twice.
Module 17: Observing and testing Workers
Cloudflare Workers154 / 194
Module 17 · Observing and testing Workers
Log objects, not sentences
With "observability": { "enabled": true }, everything your Worker logs, and how each invocation (one run of a handler) ended, is stored and searchable in the dashboard. Log objects with named fields rather than sentences: fields can be filtered and grouped, sentences can only be read.
Hard to search
console.log('redirected '+ code +' in '+ ms +'ms')
1"observability": {2"enabled":true,3"head_sampling_rate":0.1// keep 10% of invocations' logs4}
A busy Worker can keep a sample: from 0 (none) to 1 (all, the default).
Cloudflare Workers155 / 194
Module 17 · Observing and testing Workers
What never goes in a log
Logs are read by more people, and kept longer, than the data they mention. Never log secrets, tokens, passwords, whole request bodies or full Authorization headers. Prefer ids over personal details.
QuizInteractive · disabled in the PDF
Which of these is fine to log on a failed admin request?
Which of these is fine to log on a failed admin request?
Cloudflare Workers156 / 194
Module 17 · Observing and testing Workers
wrangler tail: watch it live
wrangler tail streams invocations of a deployed Worker to your terminal as they happen, with their logs and exceptions. On busy Workers it samples. Filters narrow it down: only errors, only one method, only requests from your own IP address, or text to search for.
Watch only the failing invocations of the deployed Worker.
Terminal
1npxwrangler--
Cloudflare Workers157 / 194
Module 17 · Observing and testing Workers
Metrics: the shape of the traffic
The Worker’s page in the dashboard charts requests, errors, CPU time and how long invocations took, per version. Logs explain one invocation; metrics show trends. After a deploy, watch the error rate and the CPU time of the new version against the old.
Signal
Tells you
Error rate
How often invocations throw or fail
CPU time
How much computing each invocation does
Wall time
How long it took, waiting included
Requests per version
How a gradual deployment is split
QuizInteractive · disabled in the PDF
After a release, wall time doubles but CPU time stays the same. What is the most likely cause?
After a release, wall time doubles but CPU time stays the same. What is the most likely cause?
Cloudflare Workers158 / 194
Module 17 · Observing and testing Workers
Tests prove the code still works
A test is code that runs your code and checks the result, so you know a change did not break something, without clicking through by hand. A test runner finds and runs the tests and reports which failed. Vitest is a popular runner, and Cloudflare provides an integration that runs your tests inside workerd, the real Workers runtime, with local KV, D1, R2, Durable Objects and Queues.
Test
Code that runs your code and checks what it does.
Assertion
One check in a test: expect(status).toBe(302).
Test runner
The program that runs tests and reports results: here, Vitest.
Mock
A fake stand-in for something outside your control, such as another company’s API.
Lines 1-4: Cloudflare’s plugin and a helper that reads migration files.
Lines 9: Tests get every binding wrangler.jsonc declares, simulated locally.
Lines 10-15: Extra bindings for tests only: the migrations to apply, and a stand-in secret.
Lines 18: A setup file that runs before each test file.
Older set-ups. Projects set up before this plugin use @cloudflare/vitest-pool-workers and defineWorkersConfig, with SELF from cloudflare:test to call the Worker. The idea is the same: the tests run inside workerd.
Cloudflare Workers160 / 194
Module 17 · Observing and testing Workers
Calling the Worker in a test
test/redirect.test.ts
1import{ env, exports }from'cloudflare:workers'2import{ expect, it }from'vitest'34it('redirects a known code',async()=>{5await env.LINKS.put('soup','https://example.com/soup')6const res =awaitexports.default.fetch(7newRequest('https://go.example.com/soup',{redirect:'manual'})8)9expect(res.status).toBe(302)10expect(res.headers.get('location')).toBe('https://example.com/soup')11})
exports.default is your Worker’s own default export, called through the runtime like a real request.
Fill the blanksInteractive · disabled in the PDF
Check that an unknown code gives a 404.
TypeScript
1('answers 404 for an unknown code',async()=>{2const res =awaitexports.default.(3newRequest('https://go.example.com/nope')4)5(res.status).(404)6})
Call a handler directly, then wait for what it handed to waitUntil.
QuizInteractive · disabled in the PDF
A test calls fetch on the Worker and immediately checks D1 for the click. It fails, though the click is counted in production. Why?
A test calls fetch on the Worker and immediately checks D1 for the click. It fails, though the click is counted in production. Why?
Cloudflare Workers162 / 194
Module 17 · Observing and testing Workers
Fake only the outside world
Your own bindings are real in these tests, so do not mock them. Mock what is outside: other companies’ APIs reached with fetch. Vitest’s vi.spyOn(globalThis, 'fetch') takes hold of fetch for a test, and .mockResolvedValue(…) chooses its answer, so no real request goes out.
Find the bugInteractive · disabled in the PDF
This test is meant to stay offline, but it really calls the link’s website every run. Which line?
TypeScript
Cloudflare Workers163 / 194
Module 17 · Observing and testing Workers
Try it: test against a fake fetch
The tests here do what the last slide described: vi.spyOn(globalThis, 'fetch') with mockResolvedValue or mockRejectedValue swaps the real fetch for one that answers what the test chooses, with no network at all. Write the code they test.
Code challengeInteractive · disabled in the PDF
Write 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.
TypeScript
1asyncfunctionisDead(url:string):Promise<boolean>{2// TODO: fetch it; dead if not ok, or if fetch fails3returnfalse4}
Cloudflare Workers164 / 194
Module 17 · Observing and testing Workers
Recap: observing and testing
Turn on observability and log objects with fields; sample busy Workers.
Never log secrets, tokens, or whole bodies.
wrangler tail streams live invocations, with filters such as --status error.
CPU time is computing; wall time includes waiting.
Cloudflare’s Vitest integration runs tests inside workerd with real local bindings.
Call the Worker through exports.default; wait for waitUntil work; mock only outside APIs.
FlashcardsInteractive · disabled in the PDF
Invocation
One run of a handler: one request, one cron run, one batch.
head_sampling_rate
The share of invocations whose logs are kept, from 0 to 1.
wrangler tail
Streams a deployed Worker’s live logs and exceptions.
Wall time
How long an invocation took, waiting included.
Assertion
A single check in a test, such as expect(x).toBe(1).
waitOnExecutionContext
Waits in a test for the work handed to ctx.waitUntil.
Mock
A fake stand-in for something outside your control.
Module 18: Inside the runtime: isolates, limits and speed
Cloudflare Workers165 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Many isolates share one process
On each Cloudflare machine, the workerd runtime runs one process containing many V8 isolates, one per Worker (and often several per busy Worker). Isolates share the engine but not memory: one Worker can never read another’s variables. Because no operating system has to start, an isolate is ready in a few milliseconds and uses a few megabytes.
Your Worker’s isolateits own memory and globals
Other Workers’ isolatessealed off from yours
workerd processV8, the web APIs, bindings
A Cloudflare machine
One process, many sealed-off Workers.
V8
The JavaScript engine from Chrome that runs your code.
`workerd`
Cloudflare’s open-source Workers runtime, built on V8.
Cloudflare Workers166 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Cold starts are short, and global scope decides how short
The first request to an isolate runs your module’s top-level code (the global scope: everything outside handlers) before calling fetch. A large bundle or heavy top-level work makes that first request slower. Cloudflare also starts loading your Worker while the visitor’s secure connection is still being set up, which hides much of the start.
Keep the bundle small: import only what you use.
Do no heavy work at the top level; compute lazily on first use.
Check the bundle size Wrangler prints on deploy (--dry-run shows it without deploying).
QuizInteractive · disabled in the PDF
A Worker parses a 5 MB JSON file at the top of its module. When does that cost time?
A Worker parses a 5 MB JSON file at the top of its module. When does that cost time?
Cloudflare Workers167 / 194
Module 18 · Inside the runtime: isolates, limits and speed
What lives between requests
An isolate handles many requests, sometimes at the same time, and globals keep their values between them. That is useful for things built once (patterns, lookup tables). It is dangerous for anything about a particular request: the next visitor’s request can see it. And async I/O (fetch, storage calls) is not allowed at the top level at all: it throws.
Find the bugInteractive · disabled in the PDF
Under load, visitors sometimes see someone else’s country in the response. Which line?
TypeScript
PlaygroundInteractive · disabled in the PDF
TypeScript
1// A stand-in for KV that answers after 20 ms2constslowGet=(key:string)=>3newPromise<string>((done)=>setTimeout(()=>done(key),20))45let country ='unknown'// shared by every request67const worker ={8asyncfetch(request:Request):Promise<Response>{9 country = request.headers.get('cf-ipcountry')??'unknown'10const target =awaitslowGet('soup')11return Response.json({ target, country })12}13}1415// Two visitors at the same time, in one isolate16constfrom=(where:string)=>17newRequest('https://go.example.com/soup',{18headers:{'CF-IPCountry': where }19})20Promise.all([worker.fetch(from('PE')), worker.fetch(from('JP'))])21.then((answers)=>Promise.all(answers.map((r)=> r.json())))22.then((bodies)=> console.log(bodies))
Both visitors are told JP. Make country a const inside fetch, run it again, and each gets their own.
Cloudflare Workers168 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Web standards first, Node.js on request
Workers are not Node.js. Built in are the web APIs: fetch, URL, streams, crypto.subtle and crypto.randomUUID, TextEncoder, structuredClone, timers. Many npm packages expect Node’s built-in modules (node:buffer, node:crypto…). The nodejs_compat flag provides most of them, imported with the node: prefix. It is on by default from the compatibility date 2026-08-04, so this course’s Worker has it; a Worker with an older date turns it on in the config.
JSON with comments
1"compatibility_flags": ["nodejs_compat"]
Needed only with a compatibility date before 2026-08-04.
TypeScript
1import{ Buffer }from'node:buffer'// with nodejs_compat2const id = crypto.randomUUID()// web standard, always3const bytes =newTextEncoder().encode(id)// web standard, always
QuizInteractive · disabled in the PDF
A library reads its settings with fs.readFileSync('./config.json'). Will it work in a Worker?
A library reads its settings with fs.readFileSync('./config.json'). Will it work in a Worker?
Cloudflare Workers169 / 194
Module 18 · Inside the runtime: isolates, limits and speed
The limits that shape a design
Limit
At the time of writing
CPU time per request
Free: 10 ms. Paid: 30 s by default, raised up to 5 minutes with limits.cpu_ms
Memory
128 MB per isolate
Subrequests
A cap on fetch and binding calls per invocation: 50 on Free, 10,000 on Paid
waitUntil
Up to 30 s after the response
Bundle size
Up to 64 MiB uncompressed, on both plans
CPU time is the time your code actually computes. Waiting on fetch, KV or D1 does not count: a request can wait 20 seconds on a slow API and use 5 ms of CPU. Numbers change, usually upward; check Cloudflare’s Workers limits page before designing around one.
JSON with comments
1"limits": {"cpu_ms":50}// fail fast if a request goes wild
A lower limit can also protect you: a runaway loop stops early instead of billing.
Cloudflare Workers170 / 194
Module 18 · Inside the runtime: isolates, limits and speed
How Workers are billed, in general terms
The Free plan gives a daily allowance of requests. The Paid plan charges a small monthly base that includes a large allowance, then per million requests and per million milliseconds of CPU time. Waiting is not billed. Storage products (KV, D1, R2, Durable Objects, Queues) are billed by what they store and the operations you make. Prices change: check the pricing page, but the shape stays the same.
QuizInteractive · disabled in the PDF
Which change lowers a Worker’s bill the most, all else equal?
Which change lowers a Worker’s bill the most, all else equal?
Cloudflare Workers171 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Do independent work at the same time
Each await on storage or fetch is a round trip. When two pieces of work do not depend on each other, start both and wait for both with Promise.all: the time is that of the slowest, not the sum.
One after the other: about 40 ms
const target =await env.LINKS.get(code)// ~20 msconst qr =await env.FILES.head(`qr/${code}`)// ~20 ms
1// Stand-ins for two storage calls that take 200 ms each2constslow=<T>(value:T)=>3newPromise<T>((done)=>setTimeout(()=>done(value),200))45asyncfunctiononeAfterTheOther(){6const start = Date.now()7const target =awaitslow('https://example.com/soup')8const clicks =awaitslow(42)9 console.log('one after the other:', Date.now()- start,'ms')10}1112asyncfunctiontogether(){13const start = Date.now()14const[target, clicks]=awaitPromise.all([15slow('https://example.com/soup'),16slow(42)17])18 console.log('together:', Date.now()- start,'ms')19}2021oneAfterTheOther().then(together)
Add a third slow(…) to each and compare the times again.
Cloudflare Workers172 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Run near your data when it is far away
A Worker runs near the visitor, but a D1 database lives in one region. A Worker that makes five queries per request from Sydney to a database in Europe pays five long trips. Smart Placement lets Cloudflare run the Worker near what it talks to instead, trading one longer trip (visitor to Worker) for many short ones.
JSON with comments
1"placement": {"mode":"smart"}
Split the hot path. Smart Placement moves the whole Worker. Keep the fast, cacheable path (redirects from KV) in a Worker that stays near visitors, and put the database-heavy work (stats, admin) in another Worker behind a service binding, placed near the data.
Cloudflare Workers173 / 194
Module 18 · Inside the runtime: isolates, limits and speed
Recap: inside the runtime
Workers run in V8 isolates inside workerd: separate memory, millisecond starts.
Top-level code runs once per isolate; keep it light and free of I/O.
Globals are shared between requests: never put request data in them.
Web APIs are built in; nodejs_compat adds most Node built-ins as node: imports.
CPU time, not waiting, is limited and billed; memory is 128 MB per isolate.
Promise.all for independent work; Smart Placement when data is far away.
FlashcardsInteractive · disabled in the PDF
Isolate
A sealed-off V8 context; one Worker’s memory, separate from all others.
Global scope
Module code outside handlers; runs once per isolate.
nodejs_compat
The compatibility flag that adds Node.js built-in modules; on by default from the 2026-08-04 date.
CPU time
Time spent computing, the limited and billed resource; waiting is free.
Promise.all
Waits for several promises at once; total time is the slowest.
Smart Placement
Running a Worker near the services it calls rather than near the visitor.
Module 19: Security and multi-Worker architecture
Cloudflare Workers174 / 194
Module 19 · Security and multi-Worker architecture
What can go wrong with a link shortener
Security starts with a threat model: a list of who might misuse the service, how, and what it would cost. A shortener is a tempting tool for people who want to hide where a link really goes.
Threat
Defence
Links to javascript: or other non-web schemes
Validate destinations: only https: and http:
A stolen admin token
Secrets only in env, never logged; scoped, rotated tokens
Guessing the token by timing
Constant-time comparison
Floods of link creation
Rate limits per IP and per token
SQL injection
Always bind values
Internal APIs reached from outside
No public address; service bindings only
Cloudflare Workers175 / 194
Module 19 · Security and multi-Worker architecture
Validate every destination
Never trust input. Parse the destination with URL, which throws on nonsense, then check the scheme and the length. Types in TypeScript describe what you expect; they do not check what arrives.
This check lets javascript:alert(1) through. Which line?
TypeScript
Cloudflare Workers176 / 194
Module 19 · Security and multi-Worker architecture
Try it: validate a new link
The body of a request is whatever the client sent. Typed as unknown, TypeScript makes you check each part before using it, which is exactly the habit you want.
Code challengeInteractive · disabled in the PDF
Write 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.
TypeScript
1typeNewLink={ code:string; url:string}23functionparseNewLink(input:unknown):NewLink|null{4// TODO: check each field; null for anything wrong5returnnull6}
Cloudflare Workers177 / 194
Module 19 · Security and multi-Worker architecture
Compare secrets in constant time
=== stops at the first character that differs, so a wrong guess that starts right takes slightly longer. Measured over many tries, that difference can leak a secret one character at a time: a timing attack. Workers provide crypto.subtle.timingSafeEqual, which always takes the same time for inputs of the same length.
TypeScript
1functionsameSecret(sent:string, secret:string):boolean{2const a =newTextEncoder().encode(sent)3const b =newTextEncoder().encode(secret)4if(a.byteLength !== b.byteLength)returnfalse5return crypto.subtle.timingSafeEqual(a, b)6}78const header = request.headers.get('authorization')??''9if(!sameSecret(header,`Bearer ${env.ADMIN_TOKEN}`)){10returnnewResponse('Unauthorized',{status:401})11}
timingSafeEqual throws if the lengths differ, so check them first.
QuizInteractive · disabled in the PDF
Why is sent === secret a weakness for checking a token?
Why is sent === secret a weakness for checking a token?
Cloudflare Workers178 / 194
Module 19 · Security and multi-Worker architecture
Rate limiting at the edge
The rate limiting binding counts requests per key (an IP address, a token) and says whether a key is over its limit. It counts in memory at each Cloudflare location, so it is fast and approximate: right for turning away a flood cheaply. Exact limits, shared by every location, belong in D1 or a Durable Object.
JSON with comments
1"ratelimits": [{2"name":"CREATE_LIMIT",3"namespace_id":"1001",// a number you choose4"simple":{"limit":10,"period":60}// period: 10 or 60 s5}]
Fill the blanksInteractive · disabled in the PDF
Refuse a visitor who creates too many links.
TypeScript
1const ip = request.headers.get('cf-connecting-ip')??'unknown'2const{}=await env.CREATE_LIMIT.({key: ip })3if(!success)returnnewResponse('Slow down',{status:})
Cloudflare Workers179 / 194
Module 19 · Security and multi-Worker architecture
Security headers on what your code returns
Some headers tell browsers to be stricter with your pages. Set them once, in a wrapper around every response, so error pages get them too.
TypeScript
1functionsecured(response:Response):Response{2const res =newResponse(response.body, response)3 res.headers.set('X-Content-Type-Options','nosniff')4 res.headers.set('Referrer-Policy','strict-origin-when-cross-origin')5 res.headers.set('Strict-Transport-Security','max-age=31536000')6 res.headers.set('Content-Security-Policy',"default-src 'self'")7return res8}
Header
Asks the browser to
X-Content-Type-Options: nosniff
Trust Content-Type, never guess
Strict-Transport-Security
Use https for this site from now on
Content-Security-Policy
Load scripts, styles and images only from allowed places
Referrer-Policy
Share less of the current address with other sites
Cloudflare Workers180 / 194
Module 19 · Security and multi-Worker architecture
Least privilege: each part gets only what it needs
The public redirect Worker reads KV and sends to the queue. It has no D1 binding and no admin secret.
The admin API has the write bindings and the secret, and no public address (workers_dev: false, no routes).
The queue consumer has D1 and nothing reachable by HTTP.
CI tokens can deploy Workers and nothing else.
QuizInteractive · disabled in the PDF
A bug in the public Worker lets a visitor run any code path it has. Why is least privilege worth it here?
A bug in the public Worker lets a visitor run any code path it has. Why is least privilege worth it here?
Cloudflare Workers181 / 194
Module 19 · Security and multi-Worker architecture
The shortener, grown up
A visitor opens a short link; the edge Worker answers from KV, near them.
The click goes to a queue, with no wait for the visitor.
The consumer writes clicks in batches; a cron cleans up nightly.
/api/* passes privately to the admin API, which owns writes.
Only the private Workers touch D1 and R2.
Every piece from the course, each in its place.
Cloudflare Workers182 / 194
Module 19 · Security and multi-Worker architecture
Workers that ship separately need contracts
Separate Workers deploy at different times, so for a while a new producer talks to an old consumer, or the reverse. The contract between them (queue message shapes, RPC methods, KV value formats) must change compatibly: add optional fields, never remove or rename what the other side still reads.
Put in orderInteractive · disabled in the PDF
Rename the queue message field country to region without losing any data.
1.Deploy a consumer that reads region, falling back to country
2.Remove the fallback from the consumer
3.Wait until no older consumer version is running
4.Deploy a producer that sends region instead of country
5.Wait until no messages with only country are left
Cloudflare Workers183 / 194
Module 19 · Security and multi-Worker architecture
Fail gently
Every dependency will fail at some point. Decide in advance what each failure means. If D1 is down, redirects should still work, because they only need KV; stats can wait. Wrap optional work so its failure cannot take down the essential path.
QuizInteractive · disabled in the PDF
The queue is briefly unavailable and env.CLICKS.send throws. What should the redirect do?
The queue is briefly unavailable and env.CLICKS.send throws. What should the redirect do?
Cloudflare Workers184 / 194
Module 19 · Security and multi-Worker architecture
Recap: security and architecture
Start from a threat model; allowlist input such as URL schemes.
Compare secrets with crypto.subtle.timingSafeEqual.
The rate limiting binding turns floods away cheaply; exact limits live in storage.
Set security headers on every response your code builds.
Least privilege: each Worker has only the bindings and addresses it needs.
Change contracts between Workers compatibly, and let optional work fail without breaking the essential path.
FlashcardsInteractive · disabled in the PDF
Threat model
Who might misuse a system, how, and what it would cost.
Allowlist
Accept only listed values; everything else is refused.
Timing attack
Learning a secret by measuring how long comparisons take.
429
Too Many Requests: the client is over its rate limit.
Least privilege
Each part gets only the access it needs.
Blast radius
How much damage one failure or breach can do.
Contract
The shapes two independently deployed parts agree on.
Module 20: Mastery challenge
Cloudflare Workers185 / 194
Module 20 · Mastery challenge
How to take this challenge
Each slide is a situation a team running Workers might really meet. Read the code slowly, say out loud what each line does, and only then answer. When you get one wrong, the explanation points you back to the idea to revisit.
Ask where the code runs: in a request, after it, on a schedule, in a queue, in an object.
Ask what is shared: an isolate’s globals, a location’s cache, a single object, a global store.
Ask what a failure does: retried, logged and lost, or shown to the visitor.
Cloudflare Workers186 / 194
Module 20 · Mastery challenge
The staging deploy that cannot sign in
QuizInteractive · disabled in the PDF
You 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?
You 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?
Cloudflare Workers187 / 194
Module 20 · Mastery challenge
The consumer that loses emails
Find the bugInteractive · disabled in the PDF
When the email service has an outage, some confirmation emails are never sent and never retried. Which line?
TypeScript
Cloudflare Workers188 / 194
Module 20 · Mastery challenge
Choosing the store
QuizInteractive · disabled in the PDF
The 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?
The 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?
Cloudflare Workers189 / 194
Module 20 · Mastery challenge
The rollback that did not help
QuizInteractive · disabled in the PDF
Version 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?
Version 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?
Cloudflare Workers190 / 194
Module 20 · Mastery challenge
The redirect that got slower
Find the bugInteractive · disabled in the PDF
After a refactor, redirects take as long as the D1 write again. Which line?
TypeScript
Cloudflare Workers191 / 194
Module 20 · Mastery challenge
Write the config from memory
Fill the blanksInteractive · disabled in the PDF
The redirect Worker reads LINKS, sends to the clicks queue, calls links-api, and is the site at go.example.com.
Each team may create at most 1,000 links per day, exactly. Requests come from everywhere. Which design is right?
Each team may create at most 1,000 links per day, exactly. Requests come from everywhere. Which design is right?
Cloudflare Workers193 / 194
Module 20 · Mastery challenge
Shipping a breaking feature safely
Put in orderInteractive · disabled in the PDF
Links 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.Move to 100% and later make the column required for new links
5.Deploy it gradually, watching wrangler tail --status error
Cloudflare Workers194 / 194
Module 20 · Mastery challenge
Recap: where you are now
You can explain a request’s whole journey, from DNS to your fetch handler and back.
You can choose between KV, D1, R2, Durable Objects and Queues for a given need.
You can run work after the response, on a schedule, or with retries, and know what each guarantees.
You can cache, observe, test and ship gradually, with rollbacks that work.
You can secure a Worker and split a system into Workers with least privilege and compatible contracts.
Next: read Cloudflare’s Workers docs for Workflows, WebSockets in Durable Objects and the AI and Vectorize bindings.
FlashcardsInteractive · disabled in the PDF
Secrets and environments
Each environment is its own Worker; set secrets with --env.
Ack timing
Ack a queue message only after its work succeeded.
Editable redirects
Answer 302, because browsers keep 301s.
Exact per-entity limits
A Durable Object per entity, reset with an alarm.
Safe release order
Additive migration, writers, readers, gradual deploy, then tighten.
waitUntil and await
Pass the promise; awaiting it first makes the visitor wait.
Answer key
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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'.
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.
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.)
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
1interfaceEnv{2 ADMIN_TOKEN?:string3}45functionisAdmin(request:Request, env:Env):boolean{6if(!env.ADMIN_TOKEN)returnfalse7const sent = request.headers.get('authorization')8return sent ===`Bearer ${env.ADMIN_TOKEN}`9}
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.)
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.
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.
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.
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
1// The part of KVNamespace this Worker uses2interfaceKV{3get(key:string):Promise<string|null>4delete(key:string):Promise<void>5}6interfaceEnv{7 LINKS:KV8}910const worker ={11asyncfetch(request:Request, env:Env):Promise<Response>{12const code =newURL(request.url).pathname.slice(1)13if(request.method ==='DELETE'){14await env.LINKS.delete(code)15returnnewResponse(null,{status:204})16}17const target =await env.LINKS.get(code)18if(target ===null){19returnnewResponse('Not found',{status:404})20}21return Response.redirect(target,302)22}23}
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.
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.
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.
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).
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
1// The part of KVNamespace this code uses2interfaceKV{3get(key:string):Promise<string|null>4put(5 key:string,6 value:string,7 options?:{ expirationTtl?:number}8):Promise<void>9}10typeNewLink={ code:string; url:string}1112asyncfunctioncreateLink(link:NewLink, env:{ LINKS:KV}){13const{ code, url }= link14if((await env.LINKS.get(code))!==null){15return Response.json({error:'taken'},{status:409})16}17await env.LINKS.put(code, url,{expirationTtl:60*60*24*30})18return Response.json({ code, url },{status:201})19}
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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).
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.
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.
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.
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
1interfaceMessage<Body>{2 body:Body3ack():void4retry(options?:{ delaySeconds?:number}):void5}6typeEmail={ to:string}78asyncfunctionhandleBatch(9 messages:Message<Email>[],10send:(email:Email)=>Promise<void>11){12for(const message of messages){13try{14awaitsend(message.body)15 message.ack()16}catch{17 message.retry({delaySeconds:60})18}19}20}
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.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).
16. Caching and the Cache API · Try it: normalize a cache key · Code challengeWrite cacheKey(request). It returns a new GETRequest 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:
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.