First Worker
Your first Worker responds to HTTP requests with JSON. You write it in 22 lines of F# and compile it with Fable. Then you run it on your own machine in workerd, Cloudflare's open-source Workers runtime.
- You need The .NET SDK, Node.js with npm, curl, and NuGet packages
- You get
hello-workeronlocalhost:8787
Project Folder
Create the application anywhere convenient. It restores FSharp.CloudEdge.Runtime.Workers from the 0.1.* patch series on NuGet; no sibling library checkout is required. See Packages for installation guidance and links to the full package family.
Open a terminal in the folder where you keep your projects.
Create
hello-workerand enter it.mkdir hello-worker cd hello-worker
Stay in hello-worker for the remaining commands.
Project File
Save this as hello-worker.fsproj. It declares Worker.fs, Fable.Core, and the Workers package from the 0.1.* patch series.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Worker.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Core" Version="5.2.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.CloudEdge.Runtime.Workers" Version="0.1.*" />
</ItemGroup>
</Project>
Fable.Core 5.2.0 matches the Workers package's dependency. NuGet restores the Xantham support packages transitively. The package includes the F# source needed by Fable.
Next to the project, add NuGet.Config to select the public feed. Package IDs and versions belong in PackageReference; the feed URL belongs here.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Worker Source
Put the handler in Worker.fs. It responds to / with a JSON greeting and to /time with the current UTC time. For any other route it returns a 404.
module Worker
open System
open Fable.Core
module Workers = FSharp.CloudEdge.Runtime.Workers
[<ExportDefault>]
let worker: Workers.ExportedHandler<obj, obj, obj, obj> =
Workers.ExportedHandler.Create(fetch = fun request _ _ ->
let url = Workers.Exports.URL(U2.Case1 request.url)
let response =
match url.pathname with
| "/" ->
let name = url.searchParams.get "name" |> Option.defaultValue "world"
Workers.Exports.Response.json {| greeting = $"Hello, {name}" |}
| "/time" ->
Workers.Exports.Response.json {| utc = DateTime.UtcNow.ToString "o" |}
| _ ->
let init = Workers.ResponseInit.Create(status = 404.)
Workers.Exports.Response.json({| error = "Not found" |}, U2.Case2 init)
U2.Case2 response)
[<ExportDefault>]declaresworkeras the default export of the JavaScript module. The Workers runtime passes each incoming request to thefetchhandler ofworker.ExportedHandler.Createbuilds the export from afetchfunction. The function's first argument is the request, and the two_patterns discard the environment and the execution context. All four type arguments ofExportedHandlerareobj, the F# type for any value.Workers.Exportscontains the globals your Worker can call, soWorkers.Exports.URLandWorkers.Exports.Responseare JavaScript'sURLandResponse.- An anonymous record such as
{| greeting = ... |}compiles to a plain JavaScript object, andResponse.jsonserializes it as the response body. U2.Case1andU2.Case2wrap a value as the first or second type of a TypeScript union. TheURLconstructor takes aU2<string, URL>, and thefetchfunction returns aU2<JS.Promise<Response>, Response>.
Fable Compilation
Create a tool manifest to pin .NET tool versions for
hello-worker.dotnet new tool-manifestThe template "Dotnet local tool manifest file" was created successfully.Install Fable 5.13.0, the release that the tool manifest of FSharp.CloudEdge pins.
dotnet tool install fable --version 5.13.0You can invoke the tool from this directory using the following commands: 'dotnet tool run fable' or 'dotnet fable'.Compile
hello-worker.fsprojinto thebuildfolder.dotnet fable hello-worker.fsproj -o build
Fable processes Worker.fs and the sources supplied by its NuGet dependencies. Source counts and compile times vary with the package contents. build/Worker.js contains your handler, and the Fable library code it imports is in build/fable_modules.
Emitted JavaScript
import { defaultArg } from "./fable_modules/fable-library-js.5.13.0/Option.js";
import { concat } from "./fable_modules/fable-library-js.5.13.0/String.js";
import { utcNow, toString } from "./fable_modules/fable-library-js.5.13.0/Date.js";
export const worker = {
fetch: (request, _arg, _arg_1) => {
const url = new URL(request.url);
const matchValue = url.pathname;
switch (matchValue) {
case "/": {
const name = defaultArg(url.searchParams.get("name"), "world");
return globalThis.Response.json({
greeting: concat("Hello, ", name),
});
}
case "/time":
return globalThis.Response.json({
utc: toString(utcNow(), "o"),
});
default: {
const init = {
status: 404,
};
return globalThis.Response.json({
error: "Not found",
}, init);
}
}
},
};
export default worker;
Bundled Module
esbuild resolves the imports in build/Worker.js and writes one ES module, dist/worker.js. You serve that bundle with workerd below and upload it on First Deploy.
Add a
package.jsonfor the npm tools.npm init -yInstall esbuild 0.28.2 and workerd 1.20260906.1, the releases that produced the output on this page. npm installs both with a binary for your platform.
npm install --save-dev esbuild@0.28.2 workerd@1.20260906.1added 4 packages, and audited 5 packages in 2s found 0 vulnerabilitiesBundle
build/Worker.js.npx esbuild build/Worker.js --bundle --format=esm '--external:cloudflare:*' --outfile=dist/worker.jsdist/worker.js 24.0kb
--bundle: esbuild copies the importedfable_modulescode into the output.--format=esm: the output is an ES module, with the same default export asbuild/Worker.js.--external:cloudflare:*: esbuild leavescloudflare:imports in place, for workerd and Cloudflare to resolve. A Durable Object class, for example, extends a base class fromcloudflare:workers.--outfile: the path of the bundle.
Runtime Configuration
workerd reads its settings from a Cap'n Proto text file. Put this one in hello-worker as config.capnp.
using Workerd = import "/workerd/workerd.capnp";
const config :Workerd.Config = (
services = [(name = "hello-worker", worker = .helloWorker)],
sockets = [(address = "localhost:8787", service = "hello-worker")],
);
const helloWorker :Workerd.Worker = (
modules = [(name = "worker.js", esModule = embed "dist/worker.js")],
compatibilityDate = "2026-09-06",
);
using Workerdloads the configuration schema built into workerd.const configis the configuration thatworkerd servestarts from.servicesdefines one service,hello-worker, whose Worker is thehelloWorkerconstant.- With
sockets, workerd listens on port 8787 oflocalhostand passes requests to that service. HTTP is the default protocol for a socket. const helloWorkerdefines the Worker.moduleslists one ES module,worker.js, andembedreads its contents fromdist/worker.js.compatibilityDateis required. With it, you opt into the runtime changes up to that day. 2026-09-06 is the date in 5.20260906.1, the version of@cloudflare/workers-typesused to generate the Workers library.
workerd exits at startup when the compatibility date is later than the newest date it supports, and the error message states that date. Cloudflare explains the scheme in Compatibility dates.
Local Run
Start workerd. With
--watch, it reloads the Worker after each new bundle.npx workerd serve config.capnp --watchworkerd prints nothing at startup and keeps serving until you stop it.
In a second terminal, call the Worker.
curl http://localhost:8787/{"greeting":"Hello, world"}Add a name to the query string. Quote the URL, because
?is a wildcard character in your shell.curl 'http://localhost:8787/?name=Ada'{"greeting":"Hello, Ada"}Ask for the time.
curl http://localhost:8787/time{"utc":"2026-09-29T21:16:55.773Z"}Try any other path. With
-i, curl also prints the status line and headers.curl -i http://localhost:8787/nopeHTTP/1.1 404 Not Found Content-Length: 21 Content-Type: application/json {"error":"Not found"}
Edit Loop
In watch mode, Fable keeps the project loaded and recompiles whenever you save Worker.fs. With --runWatch, Fable starts esbuild after each compile, and workerd reloads the new bundle.
In a third terminal, go to
hello-workerand start the watch.dotnet fable watch hello-worker.fsproj -o build --runWatch npx esbuild build/Worker.js --bundle --format=esm '--external:cloudflare:*' --outfile=dist/worker.jsThe initial compile takes as long as before. Fable prints
Watching ..when it is ready.In
Worker.fs, changeHellotoHowdyand save. Fable reports the recompile:Fable compilation finished in 127msworkerd reports the reload:
Reloading due to config change...Call the Worker again.
curl http://localhost:8787/{"greeting":"Howdy, world"}
Stop Fable and workerd with Ctrl+C.