lukeed

Polka, eight years later

Name

polka — polka, eight years later

Synopsis

An Express alternative in 90 lines got far more attention than it earned. Here is what it proved, what it did not, and what grew out of it.

Filed

· 1,689 words · 15 min

Description

In January 2018 I published a web server for Node.js. It was about 90 lines of code, plus a 30-line router. It got far more attention than 120 lines should get.

That was Polka. It still gets about four million downloads a month, and it still has not reached 1.0. Both facts need an explanation.

#Why it started

It started as an annoyance. I was building APIs, and every one of them began with Express. Express brought a lot of code that I did not want, and all of it was big. I never used serve-static in an API, but I still installed it, loaded it, and paid for it every time.

So I opened a new file and started to take things out. The first commit is from February 2017, and it only reserved the name. The real work started in December. The first server code and the first benchmark landed on the same day, because I wanted to know what each removal bought me. 0.1.0 was published on January 11, 2018.

That stripping back taught me more than the result did. To remove a piece of Express, I had to know what it did, why it was there, and what it cost. By the end, I knew how a Node server handles a request from the socket to my handler, and I had numbers for every step.

The idea was simple. Express is a native http.Server with routing, middleware, and a large set of helpers. Most apps I wrote used the first two and ignored the rest. So I kept the first two:

const polka = require("polka");

polka()
  .use((req, res, next) => {
    req.hello = "world";
    next();
  })
  .get("/users/:id", (req, res) => {
    res.end(`User: ${req.params.id}, ${req.hello}`);
  })
  .listen(3000);

If you know Express, you know this. That was the point.

#What made it spread

The README listed the features "in mandatory bullet-point format", and the first bullet did most of the work:

  • 33-50% faster than Express for simple applications
  • Middleware support, including Express middleware you already know
  • Nearly identical application API and route patterns
  • About 90 lines of code, 120 with its router

Two things made that list work together.

The first is that the swap was cheap. Polka used the same (req, res, next) signature, so body-parser, cors, serve-static, and most of the Express ecosystem worked unchanged. You did not have to rewrite an app to try it. You changed one import.

The second is that the speed claim was a single number that people could repeat. "Faster than Express" fits in a tweet. "A smaller middleware loop and string-based route matching" does not.

It took off on Twitter. A table where Express has the shortest bar is easy to share.

I knew at the time that the number was doing the marketing. I also knew it was true. Those two facts did not feel like a problem until later.

#What the benchmarks proved

Here is the table from the README. It used wrk on Node 10.4, four threads, four connections, ten seconds, after one warm-up run:

Server Requests/sec
Native 50,867
Polka 50,475
Rayo 49,481
Fastify 47,476
Koa 33,909
Express 20,249

The honest reading of this table is narrow. Polka added almost nothing on top of a raw http.Server for one route that returned a short string. Express added a lot. That is a real result, and it says something about how much work Express did per request.

It does not say that your app would get 2.5 times faster.

#What they did not prove

The same README section had a note under the table:

Time is mostly spent in your application code rather than Express or Polka code. Switching from Express to Polka will (likely) not show such drastic performance gains.

I wrote that note on purpose, and I still think most people skipped it.

A real request does more than return a string. It parses a body, reads a database, renders a template, and serializes JSON. Each of those costs milliseconds. The framework overhead in the table is measured in microseconds. If your handler takes 20 ms, removing 30 µs of routing is a rounding error.

So the benchmark was correct and mostly irrelevant. It measured the part of the stack that was cheapest to measure, not the part that cost the most. I wrote more about this kind of trap in How to benchmark honestly.

I do not regret the number. It got people to read the code, and the code was the useful part. But if I wrote the README today, the note would go first and the table would go second.

#What it did differently

"Nearly identical" did hide a few real differences. They are worth listing because most of them were design choices, not missing features.

Tiered middleware. Express runs middleware and routes in the order you declare them. Polka 0.x runs all use() middleware first, then the matched route. This made it faster and made sub-applications easier to compose. It also surprised people who relied on declaration order. The 1.0 line went back to the Express order, and that choice is the reason there is no 1.0.

String patterns, not regular expressions. Polka matched /users/:id, /users/:id?, and /users/*. It did not match /ab?cd or /.*fly$/. That was faster and easier to read. It also meant some Express routes did not port.

No throw from middleware. In 0.x, you passed errors to next(err). The 1.0 branch wraps the loop in an async function, so a thrown error now reaches onError too.

The app is not the handler. express() returns a function you can pass to http.createServer. Polka exposes app.handler for that. It was a small change that broke a lot of supertest setups.

#The offspring

The part of Polka that lasted is not Polka.

To keep the core small, I split out each piece that could stand on its own. Those pieces now get far more use than the server:

  • sirv serves static files. SvelteKit and webpack-bundle-analyzer depend on it, and it gets about 175 million downloads a month. Vite bundles it into its own build, so Vite installs do not count as sirv downloads. It is probably my most-used package, and most of that use gets no credit.
  • @polka/url parses req.url with a cache. It comes along with sirv, at about 163 million a month.
  • regexparam turns /users/:id into a RegExp. About 18 million a month, plus every project that copied it inline. I wrote about that in Send help, I wrote another router.
  • trouter is the router itself, at about 4.6 million.

Then came the forks and the "Polka-likes". For a few years, a new minimal Express alternative showed up every month or two. Many of them used trouter or regexparam. Some of them were faster than Polka on the same benchmark, which I found very funny.

Polka itself found its longest home in Svelte. Sapper's starter template served its app with Polka and sirv, with a comment that said you could also use Express. When SvelteKit replaced Sapper, its Node adapter kept Polka inside. It has shipped there since 2021.

I think this is the normal life of a small library. The thing with a name gets the attention. The parts underneath get the installs.

#Why there is no 1.0

The last stable release is 0.5.2, from February 2019. The first 1.0.0-next came a month later. The last one, 1.0.0-next.28, came in September 2024. That is five and a half years of prereleases.

The honest reason is that 1.0 is a different framework.

In 0.5, middleware runs in tiers: all use() middleware first, then the matched route. That is a large part of why 0.5 is fast. The 1.0 line uses the Express model instead: one stack, run in the order you declare it. It is easier to reason about, and it is slower.

Both approaches are valid, and people wanted both. But I could not ship them side by side, and an option to switch between them made no sense. They were not two settings of one framework. They were two frameworks with one name.

While I thought about that, my attention moved. I was building worktop and freshie, and nothing like them existed yet. That work was new and interesting. A third version of an Express alternative was not. The backend adapters in freshie and worktop later inspired the adapters in SvelteKit, which I worked on as part of the Svelte team.

The next line is not abandoned. It is ESM, it catches thrown errors, and SvelteKit's Node adapter runs on it. It never got the 1.0 label, and then the world moved under it.

#What I would change now

If I started Polka today, I would not start from (req, res, next).

Web Request and Response. Node, Deno, Bun, and every edge runtime now agree on the Fetch API. A handler that takes a Request and returns a Response runs anywhere. A handler that writes to res runs only on Node. I made this change in worktop and never brought it back.

No mutation. Polka, like Express, adds params, query, path, and originalUrl to the incoming req. Middleware then adds more. After four middleware calls, nobody knows the shape of req, and TypeScript cannot help. Today I would pass a separate context object and leave the request alone.

Return, do not call. A handler that returns a value can be tested by calling it. A handler that calls res.end() needs a mock response. Returning also removes a class of bugs where two middleware both try to respond.

More than one runtime. The server should be a thin adapter, and the app should not know which one it runs on.

Here is roughly what that looks like:

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

const app = new Router();

app.add("GET", "/users/:id", (req, context) => {
  return reply(200, { id: context.params.id });
});

None of that is a criticism of 2018 Polka. It matched the platform it ran on. The platform changed.

#Eight years on

Polka was a small server with a loud benchmark. The benchmark brought people in. The small size let them read the whole thing in one sitting, and I think that is why they stayed.

If you still run it, it still works. If you start something new, use the Fetch API, and take the router with you.

See also

polka(7), node(7), http(7), open-source(7), lukeed(1)