Learning web development: Implementing web servers

[2025-09-12] dev, javascript, learning web dev
(Ad, please don’t block)

This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.

To download the projects, go to the GitHub repository learning-web-dev-code and follow the instructions there.

I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.


In this chapter, we’ll write our own web server: It will serve files and manage the data for a browser app.

Terminology: browser vs. server  

The following pairs of opposites are all related:

browser server
local remote
frontend backend
client server

The term “client” is interesting because it is more general than the term “browser” – it refers to any app (web app, mobile app, etc.) that connects to a server. In web development, it usually means “browser” or “web app”.

Background: serving resources to the web  

Before we can write our first web server, we’ll need to learn more about how resources (roughly: files) are served to the web:

  • A browser sends a request to the server. It usually asks for a resource to be served.
  • The server sends back a response – usually, the data for a given resource.

HTTP requests and responses are called HTTP messages.

HTTP responses  

In version 1.1 of the HTTP protocol serves HTML pages as text, in the following format:

HTTP/1.1 200 0K
content-type: text/html
last-modified: Mon, 13 Jan 2025 20:11:20 GMT
date: Thu, 11 Sep 2025 09:55:04 GMT
content-length: 1256

<!doctype html>
<html>
...
  • Line 1 is the start line: It states the version of the HTTP protocol and a status code that tells us if everything went well (200) or an error occurred (e.g., 404). The status code can optionally be followed by a description of the code.
  • It is followed by zero or more headers – lines with key-value pairs where each key ends with a colon.
    • One important header is content-type: It specifies the media type of the served resource. text/html is the media type for HTML.
  • The headers are terminated by an empty line.
  • After that, the body contains the actual data of the response. The headers are meta-data (data about data), the body is data.

HTTP/3 is the current version of HTTP. HTTP versions after 1.1 don’t use a text format anymore but the core parts are the same: protocol version, status code, headers, content.

Tips:

  • You can use the shell command curl -i <url> to see response headers. curl may already be installed on your operating system. Otherwise, you have to install it yourself.

  • More information: “HTTP messages” on MDN

Status codes  

Each status code has a number and a description. The numbers have the following ranges:

  • 1xx: Informational – Request received, continuing process
  • 2xx: Success – The action was successfully received, understood, and accepted
  • 3xx: Redirection – Further action must be taken in order to complete the request
  • 4xx: Client Error – The request contains bad syntax or cannot be fulfilled
  • 5xx: Server Error – The server failed to fulfill an apparently valid request

These are a few examples:

  • 200 OK
  • 400 Bad Request
  • 404 Not Found
  • 500 Internal Server Error

For more information, see the official HTTP Status Code Registry.

Media types: What kind of data is served?  

In most operating systems, the filename extension reveals what kind of data is stored in a file. The web uses a different kind of mechanism for its resources: a media type (also known as a MIME type) that is attached to a resource via an HTTP header. A public standard lists all of media types; these are a few examples:

  • Plain text: text/plain
  • HTML: text/html
  • CSS: text/css
  • JavaScript: text/javascript
  • JSON: application/json
  • JPEG images: image/jpeg

Project: simple-server-html.js  

Let’s write a simple web server that only serves a single web page – whose content comes from a string. You can run that server like this:

node --watch simple-server-html.js

Option --watch means that Node.js restarts the JavaScript file every time it is changed – e.g. because we edited and saved it. Note that that only reloads the server, it does not reload what’s shown in the browser.

This is what simple-server-html.js looks like:

import { createServer } from 'node:http';

const hostname = 'localhost';
const port = 3000;

const server = createServer(
  (request, response) => {
    // ...
  }
);

server.listen(port, hostname, () => { // (A)
  console.log(`Server running at http://${hostname}:${port}/`);
});

We first create the server. Its argument is a listener for requests. It is similar to an event listener. In this case, the events are HTTP requests.

Then we start the server (line A) and specify which port it listens to.

Requests are handled as follows:

const server = createServer(
  (request, response) => {
    response.statusCode = 200;
    response.setHeader('Content-Type', 'text/html');
    const content = [
      '<!DOCTYPE html>',
      '<meta charset="UTF-8">',
      '<title>Simple Web Server</title>',
      `Path: ${request.url}`
    ];
    response.end(content.join('\n'));
  }
);

We first make two meta-data settings: the status code and the content type. We provide the content as a single chunk of text and then end (close) the response. We could also have provided the data in multiple chunks – which can make sense for large amounts of data, but it’s not something we do in this series.

Note that we are serving a minimal web page and omit as much HTML as possible. That’s normally not recommended but browsers can handle it just fine.

request.url  

The served web pages give us an opportunity to examine the paths that web servers receive when requests are made. request.url is a slight misnomer – it’s only the part of the URL that comes after the host. The following table shows a few examples:

URL of web page request.url
http://localhost:3000 /
http://localhost:3000/dir/ /dir/
http://localhost:3000/file.html /file.html

We can see that request.url always starts with a slash.

Feature of URLs: search parameters  

Search parameters (or query strings) are something that we can add after the path of a URL: They consist of a question mark (?) followed by one or more key=value pairs (the value is optional) that are separated by ampersands (&) – e.g.:

https://example.com/home.html?key1=value1&key2=value2

In URL objects, we can access this suffix via the property .search:

> const url = new URL('https://example.com/home.html?k1=v1&k2=v2');
> url.search
'?k1=v1&k2=v2'
> url.pathname
'/home.html'

Search parameters can be used to send instructions to a server. We’ll do that later in this chapter.

URLSearchParams  

Class URLSearchParams helps with parsing search parameters:

> const usp = new URLSearchParams('?k1=v1&k2=v2');
> Array.from(usp.entries())
[ [ 'k1', 'v1' ], [ 'k2', 'v2' ] ]
> usp.get('k1')
'v1'
> usp.has('k1')
true
> usp.has('KEY')
false

As we can see, instances of URLSearchParams works similarly to Maps. When we invoke the class via new then the question mark at the beginning of the string is optional and ignored.

url.searchParams  

Conveniently, each URL object has a property .searchParams with an instance of URLSearchParams:

> const url = new URL('https://example.com/home.html?key=value');
> Array.from(url.searchParams.entries())
[ [ 'key', 'value' ] ]

Omitting values  

What happens if we omit the value part of a search parameter and only provide a key? In that case, the corresponding URLSearchParams entry has the empty string as a value:

> Array.from(new URLSearchParams('?k1&k2').entries())
[ [ 'k1', '' ], [ 'k2', '' ] ]

Using the same key multiple times  

What happens if we use the same key multiple times? In Maps, the last time the key is used determines its value. In contrast, URLSearchParams records all values and returns them via .getAll():

> Array.from(new URLSearchParams('?k=v1&k=v2').entries())
[ [ 'k', 'v1' ], [ 'k', 'v2' ] ]
> new URLSearchParams('?k=v1&k=v2').getAll('k')
[ 'v1', 'v2' ]

Method .get() returns the first of those values:

> new URLSearchParams('?k=v1&k=v2').get('k')
'v1'

Percent-encoding  

Some characters, such as space, are not allowed in URLs – which is why they are encoded. Spaces are encoded via plus (+):

> Array.from(new URLSearchParams('?key=with+space').entries())
[ [ 'key', 'with space' ] ]

For other characters, percent-encoding is used: a percent sign (%) followed by the two-digit hexadecimal number of the code point of the character.

> Array.from(new URLSearchParams('?key=one%2Bone').entries())
[ [ 'key', 'one+one' ] ]
> Array.from(new URLSearchParams('?key=1%2F2').entries())
[ [ 'key', '1/2' ] ]
  • The code point of + is hexadecimal 2B (decimal 43).
  • The code point of / is hexadecimal 2F (decimal 47).

Creating search parameters  

We can also use URLSearchParams to create search parameters:

const usp = new URLSearchParams();
usp.append('key1', 'value1');
usp.append('key2', 'value with spaces');
usp.append('key3', 'one+one');

assert.equal(
  usp.toString(),
  'key1=value1&key2=value+with+spaces&key3=one%2Bone'
);

Interface vs. implementation and APIs  

In programming we distinguish:

  • An interface is the surface of a collection of functions, classes, etc.: It describes their structure (their names, how many parameters they have, etc.) and sometimes even the rules for how to use them.

  • We can implement an interface and write code that conforms to the interface. That results in one implementation of that interface. There can be more.

An interface is often implicit – e.g., with a JavaScript module, the interface is not separate from the implementation, but we can write another module that has the same interface. As a result, we can easily swap out one module for the other.

An API (Application Programming Interface) is an interface for one specific purpose – e.g.:

  • The functionality provided by browsers is often called web APIs.
  • The functionality provided by the global variable JSON (JSON.parse() etc.) can be called JavaScript’s JSON API.
  • The DOM functionality of a browser is also called the DOM API.

Project: simple-server-api.js  

In this project, we write an API server. It lets us invoke functionality that runs on that server:

const server = createServer(
  (request, response) => {
    response.statusCode = 200;
    response.setHeader('Content-Type', 'text/plain'); // (A)
    const url = new URL('file:' + request.url); // (B)
    const params = url.searchParams;
    const content = [
      'Number of search parameters: ' + Array.from(params.entries()).length
    ];
    for (const [key, value] of params.entries()) {
      content.push(
        key + '=' + value
      );
    }
    response.end(content.join('\n'));
  }
);
  • This time, we don’t serve HTML pages, we serve plain text (line A). Browsers display plain text just fine and it makes it easier for us to produce output.
  • To extract the search parameters from request.url, we use a trick: We convert request.url from an URL suffix to a real URL by prefixing the protocol file: and use the URL class to parse the result (line B).

Remote function calls  

What is happening is like a (remote) function call:

  • Input: search parameters
  • Output: plain text. We could also have returned something else – e.g., JSON (whose content type is application/json).

As an example, the server could perform addition and clients (including web apps in browsers) could invoke it like this:

http://example.com/add?num=3&num=4

Accessing the API server from the Node.js REPL  

Run simple-server-api.js and explore what it serves! You can also access it from the Node.js REPL like this:

> await (await fetch('http://localhost:3000/?key=value')).text()
'Number of search parameters: 1\nkey=value'

JavaScript: converting an object to [key, value] pairs and vice versa  

Object.entries() converts an object to an array with [key, value] pairs:

> Object.entries({a: 1})
[ [ 'a', 1 ] ]

Object.fromEntries() does the opposite:

> Object.fromEntries([['a', 1]])
{ a: 1 }

We’ll need both in the next project. Let’s look at two examples.

Example: Initializing a Map via an object  

The parameter of new Map() is an iterable with [key, value] pairs – which is what Object.entries() returns:

const map = new Map(Object.entries({
  one: 1,
  two: 2,
}));
assert.deepEqual(
  Array.from(map.entries()),
  [
    ['one', 1],
    ['two', 2],
  ]
);

Note that the keys are always strings if we use an object like this.

Example: Changing property keys  

The following code prefixes the property keys of an object with underscores:

function changePropKeys(obj) {
  return Object.fromEntries(
    Object.entries(obj).map(
      ([key, value]) => ['_' + key, value]
    )
  );
}

const obj = {
  one: 1,
  two: 2,
};
assert.deepEqual(
  changePropKeys(obj),
  {
    _one: 1,
    _two: 2,
  }
);

Project: todo-list-server  

In the previous chapter, we implemented a web app for editing a todo list in the browser. In this project, we additionally implement a web server and store the todo list there.

How should the web app interact with the server?  

In this project, we stick to a single user editing a single todo list. We want the server to store the todo list so that it doesn’t get lost when we close the web page. Two ways (among more) of doing that are:

  1. We keep the todo list model in the browser and save it to the server after each change.
  2. We keep the todo list model in the server. The browser makes API calls to change it there and receives the current model after each change.

Let’s use approach #2 because that lets us explore server-side APIs and asynchronous updates of the user interface (after each time we receive a new model from the server).

Note that browsers also support long-term storage – which means data survives the closing of a web page. However, doing that is beyond the scope of this series. You can read about the Web Storage API and the IndexedDB API on MDN if you are interested.

File system structure  

We now have two JavaScript apps: A client app and a server app. The project has the following file system structure:

  • Shared:
    • package.json contains package scripts for building the client app, starting the server app, etc. It also lists the dependencies of the client app.
    • site/: This directory contains the files that are served by the server. The client app is built into this directory.
  • Client:
    • node_modules/ contains the packages used by the client app.
    • html/: When building the client app, the files in this directory are copied to site/ (there is only a single HTML file).
    • client/: When building the client app, the JavaScript modules in this directory are bundled into site/bundle.js.
  • Server:
    • server/: This directory contains the code of the server app.
    • data/: This is where the server app stores the model after each change so that we can restart the server without losing the model.

package.json  

Among the package scripts, build and watch are basically the same as in todo-list-browser. start is different:

"scripts": {
  // ...
  "start": "node --watch ./server/server.js"
},

This time, we are using our own web server! Note that this server doesn’t automatically reload the browser whenever a file changes. Therefore, to run the app we do:

  • One terminal – server: npm start
  • Another terminal – client: npm run watch

What happens if we change something?

  • If we change the server, node automatically restarts it.
  • If we change the client, the web app is automatically rebuilt into the directory site/. As an additional step, we need to reload the web page after that.

server/server.js  

import { createServer } from 'node:http';
import { API_PATH_PREFIX, handleApiRequest } from './handle-api-request.js';
import { handleFileRequest } from './handle-file-request.js';

// ...

const server = createServer(
  async (request, response) => {
    const webPath = request.url;
    console.log('Request: '+ webPath);
    if (webPath.startsWith(API_PATH_PREFIX)) {
      await handleApiRequest(request, response, webPath);
      return;
    }
    await handleFileRequest(request, response, webPath);
  }
);

The path of the request determines what the server does:

  • Does the path start with /api/? Then the request is an API invocation.
  • Otherwise, the server serves a file.

server/handle-file-request.js  

This is how we serve a file:

  // Remove search parameters etc.
  let absPath = new URL('file:' + request.url).pathname;
  if (absPath === '/') {
    absPath = '/index.html';
  }

  // Remove leading slash
  const relPath = absPath.slice(1);
  const fileUrl = new URL(relPath, SITE_DIR);
  if (existsSync(fileUrl)) {
    response.statusCode = 200; // OK

    // Allow both uppercase and lowercase filename extensions
    const ext = path.extname(absPath).toLowerCase();
    const contentType = extensionToContentType.get(ext) ?? 'text/plain';
    response.setHeader('Content-Type', contentType);

    const content = await fs.readFile(fileUrl); // binary
    response.end(content);
    return;
  }

If the web path refers to the top-level directory of the server then we serve the index.html in that directory.

Then we convert the absolute path to a relative path by removing the leading slash. We resolve the result against the URL of the site/ directory. If there is a file at that URL then we serve it:

  • The status code is 200 because everything went well.

  • We look up the content type in a Map whose keys are filename extensions. Module path is the POSIX (which basically means Unix) version which we can use because URL paths are basically Unix paths. It was imported like this:

    import * as path from 'node:path/posix';
    
  • Finally, we read the file’s content and add it to the response. Without a second parameter, readFile() returns binary data (and not a string). That is important because we want to serve text files (HTML, JavaScript, etc.) as well as non-text files (images etc.).

If we can’t find the file, the response is an error message:

response.statusCode = 404; // Not Found
response.setHeader('Content-Type', 'text/plain');
const content = [
  'File not found: ' + absoluteWebPath
];
response.end(content.join('\n'));

What’s missing?  

This is a very simple web server. For a real web server, we’d want more features:

  • Currently only accessing the top-level directory serves the index.html in that directory. That’s functionality we want for all directories, at any level.

  • We’d want better error handling: We should catch exceptions and report them. handle-api-request.js shows how to do that.

server/handle-api-request.js  

When we start the server, we read the model from storage (last line):

// Sibling of parent directory of this module
const DATA_DIR = new URL('../data/', import.meta.url);
if (!existsSync(DATA_DIR)) {
  throw new Error('Could not find data directory: ' + DATA_DIR);
}
const CORE_MODEL_FILE = new URL('todos.json', DATA_DIR);
// ...
const coreModel = await readCoreModelFile();

This is how API requests are handled:

export const handleApiRequest = async (request, response) => {
  try {
    const url = new URL('file:' + request.url);
    // Remove the prefix '/api/' so that only the function name remains
    const functionName = url.pathname.slice(API_PATH_PREFIX.length);
    const searchParams = url.searchParams;
    const entries = Array.from(searchParams.entries());
    const params = Object.fromEntries(
      entries.map(
        ([key, value]) => [key, JSON.parse(value)]
      )
    );
    // ...
    if (functionName === 'addTodo') {
      coreModel.todos.push(
        { text: params.text, checked: false }
      );
      await writeCoreModelFile(coreModel);
      serveCoreModel(response, coreModel);
      return;
    }
    // ...
    throw new Error('Could not parse API request: ' + request.url);
  } catch (err) {
    // ...
  }
};

We first extract the functionName from the path. Then we convert the search parameters to and object. Since parameter values can only be strings, we use JSON parsing to convert them to more kinds of values (we use booleans, numbers and strings).

Each function follows the same pattern:

  • First we update coreModel.
  • Then we save the new coreModel to storage.
  • Finally, we serve it to the client.

If we catch an error then we handle it like this:

response.statusCode = 400; // Bad Request
response.setHeader('Content-Type', 'text/plain');
let content = 'An error happened: ' + String(err);
if (err.stack !== undefined) {
  content += '\n';
  content += err.stack;
}
response.end(content);

Accessing the API from the Node.js REPL  

We can now run (just) the server via npm run and interact with the API from the Node.js REPL:

> const api = 'http://localhost:3000/api/';
> await (await fetch(api + 'loadCoreModel')).json()
{ todos: [] }
> await (await fetch(api + 'addTodo?text=%22Groceries%22')).json()
{ todos: [ { text: 'Groceries', checked: false } ] }
> await (await fetch(api + 'deleteTodo?index=0')).json()
{ todos: [] }

Note how the API always sends back the complete model.

client/main.js  

With the client, we face a conundrum: On one hand, we want to display a user interface as quickly as possible. On the other hand, we first have to load the model from the server – which may take a while. Therefore, we use the following approach:

const appModel = signal(undefined);

// ...

function App() {
  if (appModel.value === undefined) {
    return html`
      <div>Loading...</div>
    `;
  }
  // ...
}

render( // (A)
  html`<${App} />`,
  document.body
);

const coreModel = await loadCoreModel(); // (B)
appModel.value = coreModel;

appModel.value is initially undefined. Therefore, when it is initially rendered (line A), the App component displays a “loading” message. After that initial rendering, we load the model (line B) and assign it to appModel.value. That triggers a re-rendering of App, which then displays a todo list.

client/app-model.js  

Where the app-model.js of todo-list-browser performed model changes itself, the todo-list-server version of that module delegates that work to the API server.

This is one model operation:

export const addTodo = async (appModel, text) => {
  const coreModel = await sendApiRequest(
    'addTodo', { text }
  );
  appModel.value = coreModel;
};

We send an API request, wait until the server returns a new model and then assign it to appModel.value – which triggers a re-rendering of the user interface.

The following function sends API requests:

const sendApiRequest = async (functionName, params) => {
  const usp = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    usp.append(key, JSON.stringify(value));
  }
  const response = await fetch(
    `/api/` + functionName + '?' + usp.toString()
  );
  const coreModel = await response.json();
  return coreModel;
};

params is an object. We convert that object to search parameters by iterating over its entries. We use JSON stringification to convert JavaScript values (booleans, numbers and strings) to strings. sendApiRequest always asynchronously returns a new model.