~managing dependencies in node.js

October 30, 2019

a journey into the world of javascript dependency management

around 2009, a groundbreaking technology named node.js emerged—a runtime enabling javascript code execution on servers.

in the same year, on october 1st, isaac introduced a new tool called npm, short for node package manager.

traditionally, javascript was confined to client-side environments, primarily used to create dynamic and interactive web pages, complemented by technologies like css for styling. however, javascript has evolved far beyond its initial scope. today, it runs on servers, browsers, mobile devices, and even unconventional platforms like tamagotchis.

"javascript is eating the world," declared kevin lacker, former cto of parse, which was acquired by facebook in 2013. javascript now powers countless platforms across industries and scales.

in 2015, brendan eich (the creator of javascript) reiterated in his talk that developers should "always bet on js," a prophecy that has proven true as javascript continues to expand its influence.

understanding node.js and common misconceptions

javascript’s cross-platform versatility is both a strength and a source of confusion. one notable misunderstanding is mistaking node.js for a programming language, rather than its true purpose as a javascript runtime.

another common issue arises when developers use browser-specific methods like fetch in server-side environments, unaware that native fetch api is only available in browsers.

despite its wide adoption, our focus here is not javascript itself but rather npm and its role in dependency management.

the evolution of dependency management

when developing javascript modules for third-party use, developers typically employ one of three patterns: amd, umd, or commonjs. these patterns aim to ensure modules are accessible and consumable within a defined namespace.

a popular example is jquery, a utility that simplifies javascript code. using jquery’s $ namespace, developers can write concise code like $(element).click(fn), which selects an element, binds a click event, and triggers a function when the event occurs.

initially, developers loaded jquery via cdns or by downloading and storing it locally. this workflow was effective but became cumbersome over time, especially when managing dependencies with conflicting version requirements.

case study: version conflicts

imagine a project using jquery version 1.12.1 alongside a plugin, jquery-carousel, which requires jquery version 2.2.4. the developer must either upgrade jquery, risking compatibility issues, or find an alternative plugin—a decision often dictated by urgency rather than best practices.

manual upgrades, involving tasks like find-and-replace or downloading updated bundles, were error-prone and time-consuming.

the rise of bower and task runners

bower emerged as "a package manager for the web," streamlining the dependency management process. however, as javascript began handling build-related tasks, tools like gulp, grunt, and brunch replaced java-based solutions like google closure compiler. these task runners introduced flexibility through plugins tailored to developers’ specific needs.

the industry then transitioned to module bundlers like webpack, rollup, and parcel. these tools optimized workflows, enabling developers to build modern user interfaces using javascript’s latest syntax and component-based approaches.

npm dependency categories

in node.js environments, dependencies are categorized into three types:

  1. runtime dependencies: required during production and execution.
  2. development dependencies: used only during development, such as testing or building processes.
  3. peer dependencies: expected to be installed and managed by the host environment.

example: peer dependencies in action

consider a jquery plugin for displaying modals. during development, the plugin requires jquery, but during deployment, it assumes jquery is already installed in the host environment. this approach avoids version conflicts and ensures compatibility.

managing dependencies with npm

dependencies

this section lists modules needed for both development and production. for instance, react should be added to dependencies if it is required in all environments. using npm install <module> or yarn add <module> without additional flags defaults to this category.

devdependencies

these are dependencies needed only in development, like webpack or eslint. to install a development-only dependency, use npm install <module> --save-dev or yarn add <module> --dev.

peerdependencies

these define dependencies that the host environment must provide. npm version 3 and above resolves peer dependencies differently, which may lead to behavior inconsistencies.

the flat structure of node_modules

npm maintains a flat directory structure for node_modules. for example:

node_modules/
├── module-a
└── module-b

if module-a and module-b both require module-c, but with different versions, npm creates nested directories:

node_modules/
├── module-a
   └── node_modules
       └── module-c@1.0.0
├── module-b
   └── node_modules
       └── module-c@2.0.0
└── module-c@3.0.0

this structure helps resolve version conflicts but can complicate workflows for tools like react-scripts, which rely on consistent dependency versions.

case study: version mismatches

a project using both react-scripts (requiring webpack 4.42.0) and next.js (requiring webpack 4.44.2) may encounter issues depending on installation order. mismatched versions can cause errors during build processes, requiring careful dependency management.

conclusion

npm has revolutionized javascript development by simplifying dependency management, enabling scalability, and supporting modern workflows. understanding its features and nuances is essential for developers navigating the ever-evolving javascript ecosystem.