Back

The modern way to write JavaScript servers

📖 tl;dr: The Request/Response-API is not just faster, but also makes writing tests easier.

If you've visited Node's homepage at some point in your life you have probably seen this snippet:

import { createServer } from "node:http";

const server = createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello World!\n");
});

// starts a simple http server locally on port 3000
server.listen(3000, "127.0.0.1", () => {
console.log("Listening on 127.0.0.1:3000");
});

It shows how to create a plain web server and respond with plain text. This the API Node became famous for and it was kinda revolutionary when it came out. These days this call might be abstracted away behind frameworks, but under the hood, this is what they're all doing.

It's nice, it works and a whole ecosystem has formed around that. However, it's not all roses. A pretty big downside of this API is that you have to bind a socket and actually spawn the server. For production usage this is perfectly fine, but quickly becomes a hassle when it comes to testing or interoperability. Sure, for testing we can abstract away all that boilerplate like supertest does, but even so it cannot circumvent having to bind to an actual socket.

// Example supertest usage
request(app)
.get("/user")
.expect(200)
.end(function (err, res) {
if (err) throw err;
});

What if there was a better API that doesn't need to bind to a socket?

Request, Response and Fetch

Enter the modern times with the fetch-API that you might be already familiar with from browsers.

const response = await fetch("https://example.com");
const html = await response.text();

The fetch()-call returns a bog standard JavaScript class instance. But the kicker is that the same is true for the request! So if we zoom out a little this API allows every server to be expressed as a function that takes a Request instance and returns a Response.

type MyApp = (req: Request) => Promise<Response>;

Which in turn means you don't need to bind sockets anymore! You can literally just create a new Request object and call your app with that. Let's see what that looks like. This is our app (I know it's a boring one!):

const myApp = async (req: Request) => {
return new Response("Hello world!");
};

...and this is how we can test it:

// No fetch testing library needed
const response = await myApp(new Request("http://localhost/"));
const text = await response.text();

expect(text).toEqual("Hello world!");

There are no sockets involved or anything like that. We don't even need an additional library. It's just plain old boring JavaScript. You create an object and pass it to a function. Nothing more, nothing less. So, how much overhead does binding to sockets end up being?

Benchmark time/iter (avg) iter/s
Node-API 806.5 µs 1,240
Request/Response-API 3.0 µs 329,300

Well, it turns out quite a bit! This benchmark might make it look like the difference are miniscule, but when let's say have a test suite that runs this server a thousand times the differences become more stark:

Spawn 1000x time/ms speedup
Node-API 1.531s -
Request/Response-API 5.29ms 289x faster

Going from 1.5s to 5.2ms which is practically instant, made it much more enjoyable to work on the tests.

How do I launch my server?

Now, to be fair, so far we haven't launched the server yet. And that's exactly the beauty of this API, because we didn't need to! The exact API depends on the runtime in use, but usually it's nothing more than a few lines. In Deno it looks like this, for example:

Deno.serve(req => new Response("Hello world!"));

What's way more important than this though is that the WinterTC group (formerly known as WinterCG) has standardized exporting your app function directly. This means that it's much easier to run your code in different runtimes without changing your code (at least the server handling stuff).

export default {
fetch() {
return new Response("hello world");
},
};

Conclusion

This API works everywhere today! The only exception here is Node and although they're part of WinterTC they haven't shipped this yet. But with a little good ol polyfilling you can teach it the modern ways to build servers. Once Node supports this natively, a bunch of tooling for frameworks will become easier.

They too, tend to try to turn every runtime into Node which is a big task and causes lots of friction.. That's the exact scenario I ran into when writing adapters for frameworks for Deno and prototyping new APIs.

Shoutout to SvelteKit which is one of the modern frameworks that nailed this aspect and made writing an adapter a breeze!

Follow me on Bluesky to get notified when the next article comes online.