Compute: The universal virtual machine
As you build in a client-server architecture, one of the questions you'll inevitably have to face is whether to have a thick or thin client. In broad strokes:
- A thick client does as much as possible on the client (server sends data)
- A thin client does as little as possible on the client (server sends markup)
There is a full spectrum between these two, and there's merit to both.
A thick client can immediately respond to updates, saves you server costs, and can be made to work offline. A thin client works great for compute constrained devices (e.g. mobile), can be updated instantly (servers receive updates faster than clients), and keeps source code secret.
Over the years we've seen the pendulum swing back and forth between the two, largely in response to:
- Increasing client capabilities: the browser being able to power common frontend apps like email or chat is what led to the rise of frontend frameworks in the first place
- Increasingly compute intensive applications: for example, most AI cannot yet run in the browser
- Decreasing compute on the client: mobile gave way to a whole new wave of thin clients... and if smart watches start having web browsers, I imagine we'll see the same cycle repeat itself
App capabilities
In recent years, we've seen progressively thicker clients, supported by a new generation of APIs giving web apps more capabilities that had previously been restricted to native apps.
Offline
"Web app" and "online" frequently go hand-in-hand, but in recent years applications have started to push those limits. Today, service workers (opens in a new tab) are the standard way to implement offline in your application.
Service workers are written as separate JavaScript / TypeScript files and intercept network requests; this:
- Allows you to serve network requests from a cache, even if the device is offline,
- Without having to change how the original webapp is written.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("./sw.js")
} else {
console.error("Service workers are not supported.");
}
The structure of the service worker script is a list of event handlers. Two core events are install
(that the service worker is being installed and should cache data) and fetch
(a request from the app was made).
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open("v1").then(function (cache) {
return cache.addAll(["/", "/offline"]);
})
);
});
self.addEventListener("fetch", function (event) {
const request = event.request;
event.respondWith(
fetch(request).catch(function () {
return caches.match("/offline");
}) as Promise<Response>
);
});
If you're using TypeScript, you can prefix the file with the following to get correct types:
/// <reference lib="WebWorker" />
// export empty type because of tsc --isolatedModules flag
export type {};
declare const self: ServiceWorkerGlobalScope;
Service workers can require a lot of setup, and Workbox (opens in a new tab) is a library from the Chrome team to make it easier.
Progressive web apps
The second largest user-facing difference between a website and an application is that an application is "installed" on the device (meaning that it is available outside the browser, offline, and alongside other native applications). Progressive web apps (opens in a new tab) (PWAs) allow users to install your webapps as apps (opens in a new tab).
A PWA works by implementing a service worker and providing a separate manifest.json
file that tells the browser about what resources it will need to run, along with metadata like name and icon. For example:
{
"name": "My PWA",
"icons": [
{
"src": "icons/512.png",
"type": "image/png",
"sizes": "512x512"
}
],
...
}
And is referenced by the webpage by adding a custom link tag:
<link rel="manifest" href="/manifest.json">
The list of required fields in a PWA manifest can be found here (opens in a new tab) and, the complete list can be found here (opens in a new tab).
Device APIs
Today, progressive web apps (and web pages generally) can do so much; some examples include:
- Read files on disk
- Access the fingerprint reader
- Record the screen
- Use bluetooth
- ...and a lot more (opens in a new tab)
Compute capabilities
Even with the capabilities of native apps, there's always been a gap between the performance of web apps and native apps. At the heart of that is a restriction that web apps need to be able to run insecure code, and sandboxing has historically added a noticible overhead.
That said, advances in compute, compilers, and languages have started to bridge that gap.
Threads
Like many interpretted languages, all JavaScript is single threaded.
And like many UI frameworks (e.g. iOS and Android), only the main thread (or JavaScript running on the page) can read or write to the DOM, to prevent race conditions. And, to take that a step further, running JavaScript in the main thread blocks rendering (because the rendering logic and JS share a thread).
When writing performance-sensitive or compute-intensive applications, this can be limiting. However, modern browsers let you overcome this by creating "workers" (opens in a new tab): JS files that run in separate threads.
Service workers, described above, are actually a subclass of webworkers, and like service workers, webworkers are structured as lists of event handlers. Communication to the main thread is done through "message passing", a method of cross-thread communication based on events.
For example, here is a script that would spin up a worker to get the nth number in the fibonacci sequence:
const myWorker = new Worker("worker.js");
myWorker.postMessage(someNumber);
myWorker.onmessage = (e) => {
console.log("Message received from worker");
console.log("Answer is: ", e.data)
};
Within the worker:
onmessage = (e) => {
const workerResult = // compute fibonacci; someNumber is stored in e.data
postMessage(workerResult);
};
Note: both of these are using the shorthand way to attach event handlers (onmessage
), you can also write:
addEventListener("message", (event) => {
// ...
});
Arraybuffers
ArrayBuffers (opens in a new tab) are a way to access raw blocks of memory. For compute intensive applications, or applications that need to load binary data into memory, they provide that access.
Memory can be shared across workers by using SharedArrayBuffers (opens in a new tab).
WASM
The last technological arc to highlight is the compilation of traditionally native languages into JavaScript, allowing near-native performance in the browser, despite being sandboxed.
It started with ASM.js and Emscripten: ASM.js (opens in a new tab) was a subset of JavaScript intended as a compile target, and Emscripten (opens in a new tab) was a backend for LLVM (a very common compiler with support for C, C++, ObjC, etc.). Emscripten could compile native code into ASM.js and even render graphics APIs directly to the screen via Canvas (JavaScript’s realtime drawing APIs).
This made for interesting technical demos, but became very real with WASM (opens in a new tab) (or WebAssembly), a browser-supported compile target for the web that was (1) safe, and (2) directly transformable into native bytecode. This made it much faster than anything interpretted; some of the early benchmarks of WASM showed that it was only 2x slower than native! While the might seem like a high number, imagine being able to run PhotoShop in your browser with only 2x overhead compared running native... that’s really fast.
For a while, this just resulted in cool demos:
However, we're starting to see some real production use cases:
- Figma uses it to power their editor (source (opens in a new tab))
- Google sheets uses it to power their formula (source (opens in a new tab))
- Notion uses it to power their clientside cache (source (opens in a new tab))
The universal virtual machine
The web platform is the closest we've ever been to a universal app runtime: a sandboxed render engine and scripting environment that runs on virtually every device: your computer, phone, watch, Tesla, fridge, and IoT devices all run JS. For something between satire and reality, The Birth and Death of JavaScript (opens in a new tab) is a funny talk that becomes more real every day.