lukeed

Before Hono, there was worktop

Name

worktop — before hono, there was worktop

Synopsis

I wrote a router for Cloudflare Workers when the platform had almost no tools. Some of its ideas became normal. Some stayed niche.

Filed

· 1,557 words · 16 min

Description

In the spring of 2021, I shipped seven minor versions of one framework in ten weeks. That was worktop, a router for Cloudflare Workers. 0.1.0 came out on March 16. 0.7.0 came out on May 25.

That pace was not discipline. It was a platform with almost no tools, and a long list of things I kept needing.

Hono's first npm release came in December of that year. Today Hono gets over 300 million downloads a month and worktop gets under two million. This is not a post about losing. It is about what it was like to be early, and which ideas made it.

#How I got here

I came to Workers as a customer with a problem. I ran stateless API servers, and I wanted to make them smaller, faster, and cheaper. A Worker had no server to keep warm and no machine to pay for while it waited. For a stateless API, that looked like the right shape.

I was intrigued, and then I would not stop talking about it. In October 2019, I published a template that rendered Svelte inside a Worker. The first worktop commit came in April 2020, and 0.0.1 came out that June. In October 2020, I gave a talk at Svelte Summit called "Svelte at the Edge", about running Svelte apps on Workers. I introduced freshie in the last minutes of it.

Soon after that talk, I went to work at Cloudflare.

#The platform then

A Worker at that time looked like this:

addEventListener("fetch", (event) => {
  event.respondWith(handle(event.request));
});

async function handle(request) {
  return new Response("hello");
}

That was the whole API surface. You got a Request, and you returned a Response. There was no router, no body parser, no cookie helper, and no CORS helper. KV had a raw binding with string values. If you wanted any of the things Express gave you, you wrote them.

Most people wrote them inline. Every Worker I read had its own if (url.pathname === ...) chain, its own JSON helper, and its own almost-correct cookie parser.

I had written that chain three times before I gave up and wrote a library.

#Why a Workers router is not a Node router

My first instinct was to port Polka. That did not last long, because nearly every assumption behind Polka was wrong here.

There is no server. Node gives you a long-lived process with http.createServer. Workers give you an event, and the runtime owns the socket. There is nothing to listen() on, and nothing to configure.

The request is a Web Request. It is not a stream you read from req.on('data'). It has .json(), .formData(), and .text(). Headers are a Headers object, not a plain object.

The response is a value. In Node you write to res over time. In a Worker you return one Response, once.

Cold starts are real, and size drives them. A Node server starts once and runs for days, so a 2 MB framework costs you nothing after boot. A Worker can start on any request, in any city. Every kilobyte of script is parsed on that path. The script also had a hard size limit.

waitUntil exists. You can respond now and keep working after. Logging, analytics, and cache writes all belong there, and there is no Node equivalent to copy from.

So worktop kept the shape people knew, req, res, and a router, and rebuilt everything under it for this platform.

#What it looked like

Here is a trimmed version of the 0.7 README example:

import { Router } from "worktop";
import * as Cache from "worktop/cache";
import { read } from "worktop/kv";
import type { KV } from "worktop/kv";

declare var DATA: KV.Namespace;

const API = new Router();

API.add("GET", "/messages/:id", async (req, res) => {
  const message = await read(DATA, `messages::${req.params.id}`);
  res.setHeader("Cache-Control", "public, max-age=60");
  res.send(200, message);
});

API.add("POST", "/messages", async (req, res) => {
  const input = await req.body();
  req.extend(fetch("https://example.com/logs", { method: "POST" }));
  res.send(201, input);
});

Cache.listen(API.run);

req.params was parsed for you. res.send serialized JSON and set the headers. req.extend was waitUntil with a friendlier name. Cache.listen checked the Cloudflare cache before routing and saved the response after.

#Submodules, not a framework

The part of worktop I am still proud of is the layout.

The package had one main export, the router, and a set of submodules: worktop/kv, worktop/cache, worktop/cookie, worktop/cors, worktop/crypto, worktop/base64, worktop/utils, and worktop/ws. Each one was its own entry in the exports map. Each one was fully tree-shakable.

If you only needed cookie parsing, you imported worktop/cookie, and nothing else ended up in your script. In a platform with a size limit and parse-time cold starts, that was not an aesthetic choice. It decided whether your Worker fit.

The whole package, with types for every submodule, was about 66 kB unpacked on npm. Its one runtime dependency was regexparam.

#Things that became normal

Some of what worktop did first, or early, is now just how this works.

Typed bindings. Worktop shipped TypeScript types for KV, the Cache API, WebSocket pairs, and Durable Objects, and typed the bindings your handlers received. Typing your environment is now the default in every Workers project.

fetch handlers that return a Response. Version 0.8 moved to the module Worker format. Handlers took (req, context) and returned a Response, instead of writing to a mutable res:

import { Router } from "worktop";
import { reply } from "worktop/response";
import { start } from "worktop/cfw";

const API = new Router();

API.add("GET", "/alive", (req, context) => {
  return reply(200, "OK");
});

export default start(API.run);

That is the shape every edge framework uses now: a Web Request in, a Response out, and a separate context for params and bindings.

Platform code at the edge of the API. In 0.8, the Cloudflare-specific modules moved behind a cfw. prefix: worktop/cfw.kv, worktop/cfw.durable, worktop/cfw.ws. The router and the response helpers no longer knew they ran on Cloudflare. That split is what lets a modern framework run on Workers, Deno, Bun, and Node with one codebase.

A starter and a CLI. create-worktop and worktop.build existed so that a new project did not begin with an hour of bundler setup. Every framework ships this now.

I do not claim any of this was invented in worktop. Several people solved the same problems in the same year. But worktop had them in one place, in a package you could read in an afternoon.

#What stayed niche

Hono won. Some of the reasons were good, and one was not.

It launched with a docs site. Worktop had a README and a folder of examples. A README is enough for a 200-byte library. A framework needs guides, recipes, and people who answer questions and are not the author. I never built that, and I did not try very hard.

It needed no setup. You installed Hono and started writing routes. With worktop, you chose a format, set up a build, and imported each piece you needed from its own submodule. I thought those imports were the point: you paid only for what you used. Most people did not want to make that choice. They wanted c.json() to be there already.

Its next tag never ended, and mine did not either. The first worktop 0.8.0-next came in September 2021. The last one came in January 2024. People do not adopt a next tag.

It led with a benchmark. From its first weeks, Hono's README said it was the fastest router for Cloudflare Workers, with a table to prove it:

hono x 748,188 ops/sec ±5.40% (77 runs sampled)
itty-router x 158,817 ops/sec ±3.62% (87 runs sampled)
sunder x 332,339 ops/sec ±1.11% (95 runs sampled)
worktop x 205,906 ops/sec ±4.43% (83 runs sampled)
Fastest is hono

I thought that table was misleading, and I mostly kept quiet about it. It measured how fast a router matches requests after it is set up and warm. That is the right test for a Node server that starts once and runs for days. It is the wrong test for Workers. A Worker can start on any request, so the cost of building the router counts as much as the cost of using it. Hono's routers did more work up front so that matching would be fast. Later, its default router compiled every route into one large regular expression. That trade makes matching fast and startup slow, and the table showed only the first part.

Hono's own docs say this now. They describe that router as fast, but say its "route registration phase can be slightly slow" and that it is "not suitable for an environment that initializes with every request." Hono later added routers for that case. But the first impression was the table, and people follow tables. I wrote about this trap in Polka, eight years later and How to benchmark honestly. I fell into it with Polka, so I recognized it.

#Was it an influence

At the start, worktop and itty-router were the main routers for Workers, and close to the only ones. Hono's early README benchmarked against both by name, and its 1.0 README listed worktop among "the other routers or frameworks for Cloudflare Workers". My code was portable and easy to read, and I am sure it had an effect. Exactly what that effect was, only Hono's authors can say.

The influence I can speak to is closer to home. The backend adapters in freshie and worktop later inspired the adapters in SvelteKit, which I worked on as part of the Svelte team.

#What I took from it

Being early means you write the platform's missing standard library. Most of that work is temporary. The platform adds the feature, or a bigger project absorbs it, and your version becomes a historical footnote.

That is fine. The useful output of worktop was not the package. It was a set of answers: put platform code at the edge, return values instead of mutating objects, make each piece importable alone, and treat script size as a feature. Those answers moved into everything I built after it, including the way I would rebuild Polka today.

The router is still on npm, and it still works. I do not mind that you probably use something else.

See also

worktop(7), cloudflare(7), workers(7), routing(7), lukeed(1)