Migrating from Express to Neutralinojs, Part 2
Jul 18, 2025
Previous posts in the series: Part 1.
This is part 2 of a series about migrating an Express-based web app to Neutralinojs, a
desktop application platform based on web technologies. Neutralinojs is an
alternative to Electron that in theory requires less resources, as it does not embed a full Chromium
build. More interestingly, Neutralinojs lets you write the backend logic using any programming
language by relying on IPC via a WebSocket to a separate process running the backend code. Neutralinojs calls those separate processes extensions.
To illustrate this migration, I use a simple picture viewing web app as an example. The code is freely available on GitHub. The migration itself is achieved through a sequence of small migrations, each isolating one interesting aspect of the whole migration:
- Migrate the Express-based web app with multiple HTTP endpoints to an Express-based web app with a single HTTP endpoint.
- Migrate the Express-based web app with a single HTTP endpoint to an Express-based web app with a WebSocket.
- Migrate from an Express-based web app with a WebSocket to a Neutralinojs app.
We completed the first migration in the last post, ending up with an Express-based web app with a
single HTTP endpoint. You can find it in directory 2-express-rest-single/
of the repository:
Today, we tackle the second migration to obtain an Express-based web app with a WebSocket.
Second Migration: A WebSocket Express-Based Web App
A WebSocket is a long-duration two-way data communication channel over TCP between a client and a
server. In that sense, it is like an Internet socket. One difference is that it is designed to work
well alongside HTTP (or HTTPS). More specifically, you can start with an existing HTTP connection
and ask it to switch to the WebSocket protocol via the HTTP Upgrade header. This makes using
WebSockets fairly transparent from a web app perspective. WebSockets are typically used when we need real-time collaboration, where messages may need to be send from the server to the client without a request by the client. For example, a chat system needs to send messages to user A's client when some other user types a message directed at user A. Without a WebSocket or something similar, the client frontend for a user would have to
poll the server at regular intervals to check if new messages are available directed at the user.
MDN has a reasonable introduction to and description of the WebSocket API in browsers.
Since Neutralinojs uses WebSockets to communicate with processes running backend logic, we prepare that forthcoming migration by modifying our Express server to use a WebSocket for
communication instead of an HTTP endpoint. The code can be found in directory 3-express-websocket
of the repository:
client/
index.html
server/
package.json
server.js
Again, there are no changes in the architecture of the web app: one directory for the client code (a
single HTML file), and one directory for the server code (using Nodejs and Express). The changes are
confined to the API
class in the index.html
client and the new WebSocket endpoint
in the server.js
server.
Let's start with the new WebSocket endpoint on the server. Instead of the app.post
route created
in the single-endpoint version of the server, we now create a WebSocket endpoint. I use the
express-ws
package to manage the creation of this server-side WebSocket.
import expressWS from 'express-ws'
expressWS(app)
app.ws('/api/ws', async (ws, req) => {
ws.on('message', async (msg) => {
const obj = JSON.parse(msg)
const callId = obj.callId
const result = await processMessage(obj)
ws.send(JSON.stringify({content: result, callId}))
})
})
The expressWS(app)
call sets up the Express app to manage WebSocket endpoints. It handles all the logic to upgrade the HTTP request to a WebSocket request, and transforms actions and messages over the WebSocket into events. From the perspective of the server, all we need to do is create a route (here, /api/ws
) and expose it as a WebSocket. As we'll see below, a client only has to open a WebSocket connection to /api/ws
and everything should flow beautifully.
WebSockets work through events triggered when WebSockets connect, receive messages, close, or error out. For simplicity, the server above only handles receiving messages; a production system would need a lot more error checking.
The HTTP-based server uses a JSON object to pass and return information via the POST
endpoint. Our WebSocket will use the same JSON. It needs to be stringified explicitly though. Recall that the JSON object sent by the client has a field mode
indicating the operation to perform (get-images
, get-image
, post-image
) and other fields hold whatever parameters are pertinent to the operation. For example, this is the JSON for downloading and adding a new picture to the server list:
{
"mode": "post-image",
"url": URL
}
where URL
is the URL of the picture to download.
When a message is received over the WebSocket by the server and triggers the message
event, the body
of the message is parsed into a JSON object and handed over to the processMessage()
function to
perform the operation represented by the JSON object. The processMessage()
function is exactly as
before, requiring no change. The result of the operation is wrapped into a JSON object that is
stringified and sent back to the client over the WebSocket.
There is one slight complication with switching from an HTTP endpoint to a WebSocket endpoint. The
HTTP protocol is naturally a request-response protocol: when you send an HTTP request, you can wait
for the response and you will get the response that corresponds exactly to the request you sent. If
you send two requests at the same time, you can wait for the respective responses and they will not
conflict with each other — the protocol keeps track of which response correspond to which
request. The WebSocket protocol, in contrast, is a two-way shared communication channel that has no
concept of request and response. Either end of the channel can post a message on the channel to send
it to the other end. There is no notion of a response to that message. If we want to impose a
request-response structure on top of a WebSocket, we need to do it ourselves. The easiest way is
simply to attach a unique ID to every message sent, and when we send back what we consider a
response to that message, we attach that ID to the response. The client who sent the original
request can wait for a message that has a matching ID and ignore all others.
In the code above, you see that the JSON object received has a field callId
, which is how that
unique ID will be passed by the frontend code. That callId
is attached to the response before it is
sent back. The sample code from Small Technology Foundation's WebSocket RPC example was an an
inspiration here.
To round up the description of the server, note that we still need the "catch-all" endpoint to serve
the frontend from the static client/
directory.
On the frontend side, we replace the previous API
class with a new API
class that creates a WebSocket connection to /api/ws
and sends every message to that WebSocket:
class API {
constructor() {
const websocket = new WebSocket("/api/ws", "ws")
this._websocket = websocket
websocket.onerror = (event) => {
console.dir(event, {depth:null})
}
this._isReady = new Promise((resolve) => {
websocket.onopen = (event) => {
resolve(true)
}
})
this._callId = 0
}
async _fetch(_, obj) {
await this._isReady
const websocket = this._websocket
// callId is a unique identifier for the call so that we can catch the response.
const callId = this._callId
this._callId += 1
const response = new Promise((resolve) => {
const listener = (event) => {
const data = JSON.parse(event.data)
if (data.callId === callId) {
websocket.removeEventListener('message', listener)
resolve(data)
}
}
websocket.addEventListener('message', listener)
})
websocket.send(JSON.stringify({...obj, callId}))
const data = await response
return data.content
}
async fetchImages() {
return this._fetch("/api/message", {
"mode": "get-images"
})
}
async fetchImage(index) {
return this._fetch(`/api/message`, {
"mode": "get-image",
"index": index
})
}
async addImage(url) {
return this._fetch("/api/message", {
"mode": "post-image",
"url": url
})
}
}
Only the constructor and the _fetch()
method have been modified. Let's look at them in order.
First, the constructor creates the WebSocket connection to the /api/ws
WebSocket endpoint on the server. It
stores the resulting WebSocket connection and initializes the counter callId
that keeps track of the
unique IDs we'll need to associate to every sent message.
Second, the _fetch()
method, which is used by the three API methods fetchImages
, fetchImage
, and addImage
, does the bulk of the work. It adds a callId
to the JSON object representing the request (incrementing the callId
for the next call) and sends the stringified JSON object over the WebSocket. It also sets up a dedicated event listener for the specific callId
that went out that waits for a message back from the WebSocket. When a message arrives, the listener checks if the callId
matches the callId
of the message it is associated with: if it's a match, then the message is taken to be the response and the _fetch()
can return. If it's not a match, the message is discarded. (Don't worry — if that message was a response to some other request, then that request's event listener will process the response.) To prevent a memory leak, when a response to a request is received, the event listener associated with the request self-destructs.
Perhaps astonishingly, this works. I'm not sure how expensive it is to keep a long-running WebSocket
open for each user session if we were to put this server online, but since this is meant as a
step towards a single-user desktop application, I'm not particularly worried about this.
Next time, we'll take the plunge and migrate the app to Neutralinojs. Stay tuned!
Sidi (by Arturo Perez-Reverte)
This post was written entirely by a human.
Previous posts in the series: Part 1.
This is part 2 of a series about migrating an Express-based web app to Neutralinojs, a desktop application platform based on web technologies. Neutralinojs is an alternative to Electron that in theory requires less resources, as it does not embed a full Chromium build. More interestingly, Neutralinojs lets you write the backend logic using any programming language by relying on IPC via a WebSocket to a separate process running the backend code. Neutralinojs calls those separate processes extensions.
To illustrate this migration, I use a simple picture viewing web app as an example. The code is freely available on GitHub. The migration itself is achieved through a sequence of small migrations, each isolating one interesting aspect of the whole migration:
- Migrate the Express-based web app with multiple HTTP endpoints to an Express-based web app with a single HTTP endpoint.
- Migrate the Express-based web app with a single HTTP endpoint to an Express-based web app with a WebSocket.
- Migrate from an Express-based web app with a WebSocket to a Neutralinojs app.
We completed the first migration in the last post, ending up with an Express-based web app with a
single HTTP endpoint. You can find it in directory 2-express-rest-single/
of the repository:
Today, we tackle the second migration to obtain an Express-based web app with a WebSocket.
Second Migration: A WebSocket Express-Based Web App
A WebSocket is a long-duration two-way data communication channel over TCP between a client and a server. In that sense, it is like an Internet socket. One difference is that it is designed to work well alongside HTTP (or HTTPS). More specifically, you can start with an existing HTTP connection and ask it to switch to the WebSocket protocol via the HTTP Upgrade header. This makes using WebSockets fairly transparent from a web app perspective. WebSockets are typically used when we need real-time collaboration, where messages may need to be send from the server to the client without a request by the client. For example, a chat system needs to send messages to user A's client when some other user types a message directed at user A. Without a WebSocket or something similar, the client frontend for a user would have to poll the server at regular intervals to check if new messages are available directed at the user.
MDN has a reasonable introduction to and description of the WebSocket API in browsers.
Since Neutralinojs uses WebSockets to communicate with processes running backend logic, we prepare that forthcoming migration by modifying our Express server to use a WebSocket for
communication instead of an HTTP endpoint. The code can be found in directory 3-express-websocket
of the repository:
client/
index.html
server/
package.json
server.js
Again, there are no changes in the architecture of the web app: one directory for the client code (a
single HTML file), and one directory for the server code (using Nodejs and Express). The changes are
confined to the API
class in the index.html
client and the new WebSocket endpoint
in the server.js
server.
Let's start with the new WebSocket endpoint on the server. Instead of the app.post
route created
in the single-endpoint version of the server, we now create a WebSocket endpoint. I use the
express-ws
package to manage the creation of this server-side WebSocket.
import expressWS from 'express-ws'
expressWS(app)
app.ws('/api/ws', async (ws, req) => {
ws.on('message', async (msg) => {
const obj = JSON.parse(msg)
const callId = obj.callId
const result = await processMessage(obj)
ws.send(JSON.stringify({content: result, callId}))
})
})
The expressWS(app)
call sets up the Express app to manage WebSocket endpoints. It handles all the logic to upgrade the HTTP request to a WebSocket request, and transforms actions and messages over the WebSocket into events. From the perspective of the server, all we need to do is create a route (here, /api/ws
) and expose it as a WebSocket. As we'll see below, a client only has to open a WebSocket connection to /api/ws
and everything should flow beautifully.
WebSockets work through events triggered when WebSockets connect, receive messages, close, or error out. For simplicity, the server above only handles receiving messages; a production system would need a lot more error checking.
The HTTP-based server uses a JSON object to pass and return information via the POST
endpoint. Our WebSocket will use the same JSON. It needs to be stringified explicitly though. Recall that the JSON object sent by the client has a field mode
indicating the operation to perform (get-images
, get-image
, post-image
) and other fields hold whatever parameters are pertinent to the operation. For example, this is the JSON for downloading and adding a new picture to the server list:
{
"mode": "post-image",
"url": URL
}
where URL
is the URL of the picture to download.
When a message is received over the WebSocket by the server and triggers the message
event, the body
of the message is parsed into a JSON object and handed over to the processMessage()
function to
perform the operation represented by the JSON object. The processMessage()
function is exactly as
before, requiring no change. The result of the operation is wrapped into a JSON object that is
stringified and sent back to the client over the WebSocket.
There is one slight complication with switching from an HTTP endpoint to a WebSocket endpoint. The HTTP protocol is naturally a request-response protocol: when you send an HTTP request, you can wait for the response and you will get the response that corresponds exactly to the request you sent. If you send two requests at the same time, you can wait for the respective responses and they will not conflict with each other — the protocol keeps track of which response correspond to which request. The WebSocket protocol, in contrast, is a two-way shared communication channel that has no concept of request and response. Either end of the channel can post a message on the channel to send it to the other end. There is no notion of a response to that message. If we want to impose a request-response structure on top of a WebSocket, we need to do it ourselves. The easiest way is simply to attach a unique ID to every message sent, and when we send back what we consider a response to that message, we attach that ID to the response. The client who sent the original request can wait for a message that has a matching ID and ignore all others.
In the code above, you see that the JSON object received has a field callId
, which is how that
unique ID will be passed by the frontend code. That callId
is attached to the response before it is
sent back. The sample code from Small Technology Foundation's WebSocket RPC example was an an
inspiration here.
To round up the description of the server, note that we still need the "catch-all" endpoint to serve
the frontend from the static client/
directory.
On the frontend side, we replace the previous API
class with a new API
class that creates a WebSocket connection to /api/ws
and sends every message to that WebSocket:
class API {
constructor() {
const websocket = new WebSocket("/api/ws", "ws")
this._websocket = websocket
websocket.onerror = (event) => {
console.dir(event, {depth:null})
}
this._isReady = new Promise((resolve) => {
websocket.onopen = (event) => {
resolve(true)
}
})
this._callId = 0
}
async _fetch(_, obj) {
await this._isReady
const websocket = this._websocket
// callId is a unique identifier for the call so that we can catch the response.
const callId = this._callId
this._callId += 1
const response = new Promise((resolve) => {
const listener = (event) => {
const data = JSON.parse(event.data)
if (data.callId === callId) {
websocket.removeEventListener('message', listener)
resolve(data)
}
}
websocket.addEventListener('message', listener)
})
websocket.send(JSON.stringify({...obj, callId}))
const data = await response
return data.content
}
async fetchImages() {
return this._fetch("/api/message", {
"mode": "get-images"
})
}
async fetchImage(index) {
return this._fetch(`/api/message`, {
"mode": "get-image",
"index": index
})
}
async addImage(url) {
return this._fetch("/api/message", {
"mode": "post-image",
"url": url
})
}
}
Only the constructor and the _fetch()
method have been modified. Let's look at them in order.
First, the constructor creates the WebSocket connection to the /api/ws
WebSocket endpoint on the server. It
stores the resulting WebSocket connection and initializes the counter callId
that keeps track of the
unique IDs we'll need to associate to every sent message.
Second, the _fetch()
method, which is used by the three API methods fetchImages
, fetchImage
, and addImage
, does the bulk of the work. It adds a callId
to the JSON object representing the request (incrementing the callId
for the next call) and sends the stringified JSON object over the WebSocket. It also sets up a dedicated event listener for the specific callId
that went out that waits for a message back from the WebSocket. When a message arrives, the listener checks if the callId
matches the callId
of the message it is associated with: if it's a match, then the message is taken to be the response and the _fetch()
can return. If it's not a match, the message is discarded. (Don't worry — if that message was a response to some other request, then that request's event listener will process the response.) To prevent a memory leak, when a response to a request is received, the event listener associated with the request self-destructs.
Perhaps astonishingly, this works. I'm not sure how expensive it is to keep a long-running WebSocket open for each user session if we were to put this server online, but since this is meant as a step towards a single-user desktop application, I'm not particularly worried about this.
Next time, we'll take the plunge and migrate the app to Neutralinojs. Stay tuned!