Server-Side JavaScript Arc Part 1: A Retrospective on the Golden Age of Node.js

I wrote the initial draft of this post in January 2023, over three years ago.

My Node.js Journey

Two decades ago I first wrote and fell in love with PHP (circa v5.4). It allowed me to build web applications that I could share with my friends. It even helped me pay for college. At first I wrote CRUD apps then I started making games. The frontends I wrote were mostly static but eventually I learned enough JavaScript and CSS to make things interesting. In 2006 I built a "realtime" polling AJAX browser game where my friends and I could walk around a shared world together. There was a clear distinction between what ran on the server and what ran on the browser as the programming languages were different. PHP does a very good job writing applications tied to a single request, and JavaScript does a very good job writing event driven applications.

Cobalt Calibur v2 makes repeated "AJAX" requests to communicate position updates
Cobalt Calibur v2 makes repeated "AJAX" requests to communicate position updates

A decade after that I discovered Node.js (circa v0.4). Nearly overnight I stopped writing PHP and went all in on Node.js. Sure, I no longer had a syntactical way to differentiate what code ran on the server and what ran in the browser, but it wasn't all that bad. There was some context shift required, notably the version of JavaScript that shipped with Node.js prior to version 1.0 was older than what ran in browsers, but I made due. We all did. In 2012 I built a realtime Websocket browser game where my friends and I could terraform a world together.

Cobalt Calibur v3 uses Websockets to communicate position updates
Cobalt Calibur v3 uses Websockets to communicate position updates

Using a common language for code running in the browser and on the server was neat, perhaps even clever, but it was never why I liked Node.js. In my mind, much like a decade before when I wrote PHP + JavaScript, the code I was writing now was still distinct. There was the server JavaScript, and then there was the browser JavaScript. Different APIs and different contexts. The patterns I implemented in each was different. Error handling, performance, and security was a much higher priority on the server than in the browser.

Watching the runtime evolve over the next several years was quite a journey. There was the drama, the io.js fork, and later the great merger. The major version numbers continued to tick upward, V8 got faster, JavaScript improvements landed as well. As the language evolved native modules from the community were replaced with pure JavaScript and deployments got easier. npm finally introduced a lock file and we no longer had to share node_modules tarballs with colleagues.

To this day I slap an io.js sticker on my laptops
To this day I slap an io.js sticker on my laptops

Peak Node.js

Node.js reached an amazing place somewhere around the v10/v12 era. Node.js core was still growing fast but it was growing with technology that made sense for a server platform. Finding "pure backend" Node.js jobs was easy; plenty of companies built backend services which didn't output some semblance of HTML.

Of course, development doesn't happen in isolation. Node.js gained popularity and it was due in large part to the ecosystem of npm packages. This one-language-everywhere pattern really helped Node.js gain developers as well. Overnight, companies with a dozen "frontend engineers" could adopt Node.js and then have a dozen "fullstack engineers".

Build tooling written in JavaScript, running on Node.js, was constructed to help evolve the language. Features and syntax that were still in the planning phase but weren't implemented in JavaScript engines could be written by developers then transpiled into a version of JavaScript that could run anywhere. The community could then experiment with these features, suss out issues, and worthy enhancements were incorporated into JavaScript itself.

The upstreaming of functionality is a very powerful thing indeed. Implementing features in userspace en masse proves the importance of said features. CoffeeScript lead the way for arrow functions. The left-pad debacle inspired String#padStart(). The dozens of highly downloaded UUID generation packages lead the way for crypto.randomUUID(). Truly the desired fate of userspace functionality is to be made obsolete after getting upstreamed.

Of course, other build tooling came about as well. Linters and test runners are obvious ones. Some became more ingrained, such as JSX and React, and of course TypeScript. Building this tooling with Node.js made a lot of sense. The developers consuming the tools are therefore able to contribute to them.

This is about when the term Isomorphic-, and later Universal-JavaScript were coined. All of this build tooling, and the ubiquity of the npm registry, made it so that developers could write JavaScript that can run in the browser and on the server. A lot of this is obvious wins, like validating that a string meets certain requirements. Other Universal JavaScript was a bit more complex, for example code that would rely on a browser-only API or a Node.js-only API would then need some sort of polyfill or other form of transpilation to make it work. For example, a Node.js fs filesystem polyfill might write content to LocalStorage when transpiled for the browser.

JavaScript Burnout

This is about when JavaScript burnout really started happening. The typical Node.js project, especially those of moderate complexity, then had so much build tooling involved that maintenance became difficult. Bit rot, a term referring to old projects that become difficult to run with the passage of time, is certainly expedited with code related to build tooling. For the most part a "non-universal" Node.js application, particularly with few npm packages, continues to run over time with minimal change. However those with complex build tooling and universal JavaScript end up being the most fragile.

Complex frameworks that obscure the distinction between code that runs on the backend and on the frontend also became ubiquitous. The industry seemed to have converged on tooling like Next.js, Nest.js, React, etc. I often look through the code of old colleagues or clients and ask them if a given line of code runs on the server or in a browser and it was surprising how often folks don't know the answer.

Of course, issue trackers and complaints and trends in pull requests and community interactions start to follow a trend. Maintainers of Node.js see that a lot of these polyfills and transpilation issues are frustrating the community and so the platform evolves. Node.js, which started off as a way to run servers, began pulling in more and more APIs from the browser.

Some of these borrowed APIs are a very obvious win for Node.js. Before TypedArrays existed, Node.js needed a way to represent binary data and so invented the Buffer. With the modern JavaScript provided by V8 we now have Typed Arrays. So, it makes sense to update the Node.js APIs to accept Typed Arrays where Buffers are also accepted. Similarly beneficial APIs include Web Streams, Web Crypto, URL and friend URLSearchParams, Intl, and tons of things we take for granted like setTimeout.

Some of these borrowed APIs come with caveats. The fetch() API, which offers friendlier ergonomics than the built-in http module 99% of the time, has security caveats when adopting the API, particularly because it was designed for single-user usage (a single user's browser window) but is now used to represent multiple user operations (a server servicing many users). Overall the API made for a nice replacement to the once-ubiquitous request library. It also benefits developers that they can read about the fetch() API by visiting the MDN docs.

ECMAScript modules (ESM, import and export) is another feature that was designed for browsers, had early transpiled versions in Node.js, and then made its way into core Node.js. The transition from CJS to ESM has been a bumpy ride. I've long felt that the transition from CommonJS (CJS, require()) to ESM was the Node.js version of Python's tumultuous v2 to v3 transition.

Finally, there are APIs that offer questionable benefit to a server environment and that really only appease the crowd looking to offload work from the transpilers or otherwise who wish to write universal JavaScript. Think of globalThis or btoa and atob or alert or anything related to cookies.


As browser and server concepts converge, Node.js became more powerful, but also more complex. Such complexity doesn't remain isolated but instead propagates into application code, build pipelines, and community conventions, making it harder to reason about what runs where and why. Cognitive overhead accumulates. The cost of this manifests both as fragile codebases and developer fatigue.

For part two of this blog post check out A Server-First JavaScript Runtime.

Thomas Hunter II Avatar

Thomas Hunter II is a Software Engineer with 18+ years building scalable, production-grade systems. Deep expertise in Node.js, observability, and developer tooling, with a strong track record of driving architectural decisions, mentoring engineers, and improving customer facing technical documentation. Author of 5 books (including O'Reilly), certified by the OpenJS Foundation, and frequent international conference speaker.