Running Haskell on Cloudflare’s Edge with WebAssembly
Cloudflare Workers leverages Google V8 to execute JavaScript and WebAssembly at the edge. While JavaScript is the default, WebAssembly offers a portable, sandboxed binary format ideal for compute-heavy, self-contained tasks. For developers who prefer strong static typing, Haskell compiles to WebAssembly via Asterius, bringing purity, type inference, and composable functions to serverless edge computing.
Preparing the Asterius Toolchain
Asterius provides prebuilt container images that bundle the compiler and runtime. Using podman, pull the 200617 image, which pairs with GHC 8.8:
podman run -it --rm -v $(pwd):/workspace -w /workspace terrorjack/asterius:200617
Within that environment, start with a simple Haskell module. The following pure function, fact, is tail-recursive and exported via the Asterius JavaScript FFI:
module Factorial (fact) where
fact :: Int -> Int
fact n = go n 1
where
go 0 acc = acc
go n acc = go (n - 1) (n*acc)
foreign export javascript "fact" fact :: Int -> Int
To invoke it from Node.js, create an entry file that loads the Asterius runtime and the generated WebAssembly loaders. The fact call is asynchronous, as are all Asterius-exported functions, even pure ones:
import * as rts from "./rts.mjs";
import module from "./fact.wasm.mjs";
import req from "./fact.req.mjs";
async function handleModule(m) {
const i = await rts.newAsteriusInstance(Object.assign(req, {module: m}));
const result = await i.exports.fact(5);
console.log(result);
}
module.then(handleModule);
Compile with ahc-link, pointing it at the Haskell source and the custom JavaScript entry, with no main function exported:
ahc-link \
--input-hs fact.hs \
--no-main \
--export-function=fact \
--run \
--input-mjs fact_node.mjs \
--output-dir=node
Executing the bundled file in Node prints the computed factorial:
[INFO] Compiling fact.hs to WebAssembly
...
[INFO] Running node/fact.mjs
120
Deploying the Module to Workers
Cloudflare Workers requires a metadata.json to define the wasm_module binding. The name field becomes the global variable your Worker code uses to access the module—here, WASM:
{
"body_part": "script",
"bindings": [
{
"type": "wasm_module",
"name": "WASM",
"part": "wasm"
}
]
}
The Worker script itself imports the same runtime and request-oriented loaders. An async handleFact function instantiates Asterius with the global WASM module and calls the exported fact. The handleRequest function expects a POST with a numeric param field in the body, returning the result:
import * as rts from "./rts.mjs";
import fact from "./fact.req.mjs";
async function handleFact(param) {
const i = await rts.newAsteriusInstance(Object.assign(fact, { module: WASM }));
return await i.exports.fact(param);
}
async function handleRequest(req) {
if (req.method == "POST") {
const data = await req.formData();
const param = parseInt(data.get("param"));
if (param) {
const resp = await handleFact(param);
return new Response(resp, {status: 200});
} else {
return new Response(
"Expecting 'param' in request to be an integer",
{status: 400},
);
}
}
return new Response("Method not allowed", {status: 405});
}
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request))
})
Note: The fetch event listener and handleRequest rely on the Service Workers API, and all code must be bundled into a single file. Asterius includes Parcel.js for that purpose. The ahc-link output lands in a worker directory; keep only the bundled fact.js and fact.wasm.
Upload both artifacts via the Workers REST API. You’ll need your account ID, a script name, and an API token:
cd worker
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/workers/scripts/$SCRIPT_NAME" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-F "[email protected];type=application/json" \
-F "[email protected];type=application/javascript" \
-F "[email protected];type=application/wasm"
After uploading, test the script in the Workers UI. If enabled on a workers.dev subdomain, the deployment is immediately reachable:
curl -X POST $CFW_SUBDOMAIN \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'param=5'
Scaling Up to Cabal Projects
Beyond single-file modules, Asterius supports full Cabal projects. The ahc-cabal CLI lets you build dependencies, and ahc-dist converts the resulting binary. The project scaffold is standard:
ahc-cabal init -m -p cabal-cfw-example
Define a datatype with Template Haskell—Asterius supports it—and note the -optl--export-function=handleReq flag in the Cabal file, which is mandatory for exporting functions from a project:
cabal-version: 2.4
name: cabal-cfw-example
version: 0.1.0.0
license: NONE
executable cabal-cfw-example
ghc-options: -optl--export-function=handleReq
main-is: Main.hs
build-depends:
base,
bytestring,
aeson >=1.5 && < 1.6,
text
default-language: Haskell2010
The example uses a User record. The handleReq function takes two strings and returns a JavaScript Response object created via a helper:
handleReq :: JSString -> JSString -> IO JSObject
handleReq method rawBody =
case fromJSString method of
"POST" ->
let eitherUser :: Either String User
eitherUser = eitherDecode (B8.pack $ fromJSString rawBody)
in case eitherUser of
Right _ -> js_new_response (toJSString "Success!") 200
Left err -> js_new_response (toJSString err) 400
_ -> js_new_response (toJSString "Not a valid method") 405
foreign export javascript "handleReq" handleReq :: JSString -> JSString -> IO JSObject
foreign import javascript "new Response($1, {\"status\": $2})"
js_new_response :: JSString -> Int -> IO JSObject
Build with ahc-cabal to produce an executable, then pass it to ahc-dist for WebAssembly:
ahc-dist --input-exe cabal-cfw-example --export-function=handleReq --no-main --input-mjs cabal_cfw_example.mjs --bundle --browser
The minimal entry file loads the runtime and the request handler:
import * as rts from "./rts.mjs";
import cabal_cfw_example from "./cabal_cfw_example.req.mjs";
async function handleRequest(req) {
const i = await rts.newAsteriusInstance(Object.assign(cabal_cfw_example, { module: WASM }));
const body = await req.text();
return await i.exports.handleReq(req.method, body);
}
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request))
});
Deployment follows the same pattern as the simple example: define metadata.json and upload the script alongside the WebAssembly module.
Important Caveats
Cloudflare enforces size limits on both JavaScript and WebAssembly assets. Monitor the binary footprint of any added Haskell packages. The prebuilt container already includes many common libraries, so lean on those where possible to avoid bloat when working with ahc-cabal.
Takeaways
The combination of Haskell’s expressive static types, Asterius’s JavaScript FFI, and Cloudflare’s edge runtime enables type-safe, pure functional code to run in a serverless environment. The prebuilt toolchain and Template Haskell support reduce friction, and the Workers API handles the final step from local build to live deployment.



