Node.js os Module: CPU, Memory & Network Interfaces
The node:os module exposes information about the host that the current program is running on. That split is the useful way to think about it: process tells you about this particular Node process, while os tells you about the machine and platform around it. CPU data, available parallelism, memory totals, load averages, network interfaces, paths, user information, platform identifiers, and constants all come from that wider system view.
The os Module
Most values returned by this module are snapshots gathered through libuv or platform APIs at the moment you ask for them. That is important because the host is not static. Free memory changes as other processes allocate and release pages, network interfaces can appear or disappear, and container limits may affect how much parallel work the process can actually use.
You import the module in the usual built-in-module form:
const os = require('node:os');From there, the API gives JavaScript code a narrow view into values normally owned by the operating system. Many functions are thin wrappers around libuv calls, and libuv in turn wraps platform-specific system calls or system files. For changing values, treat each call as a fresh reading rather than a cached property of the process. Calling os.freemem() twice a second apart can produce different numbers if memory pressure changed in between.
The module contains roughly two dozen functions and a handful of constants. Some are simple startup values. Others, such as CPU timing, memory availability, load average, and network interface data, often feed monitoring endpoints, health checks, and capacity planning scripts. The rest of this chapter follows those APIs from the resource-level values down to the platform details that shape them.
CPU Information
os.cpus() returns an array with one object for each logical CPU core.
const cpus = os.cpus();
console.log(cpus.length);
console.log(cpus[0]);A single entry looks like this:
{
model: 'Apple M1 Pro',
speed: 2400,
times: { user: 483200, nice: 0, sys: 198300, idle: 2918400, irq: 0 }
}The model string comes from the operating system. speed is the reported clock speed in MHz. The times object is the part that carries the most operational meaning: each field is a millisecond count showing how much time that logical CPU has spent in a particular state since boot.
user is time spent running user-space code, including your application and Node itself. sys is kernel-space time spent in syscalls, scheduling, context switches, interrupt handlers, and device drivers. idle is time when the core had no work to run. nice tracks time spent on lower-priority processes that have been "niced" to yield CPU time to others; on Windows, this value is always 0. irq measures time spent handling hardware interrupts such as disk completion events, incoming network packets, and timer interrupts.
The word "logical" is important. On a machine with 8 physical cores and hyperthreading enabled, os.cpus() returns 16 entries because each physical core exposes two hardware threads. The kernel schedules work onto those hardware threads as separate logical CPUs, even though pairs of them share execution resources. If you are trying to estimate physical core count, os.cpus().length overcounts on hyperthreaded machines.
Apple Silicon has a different shape. M-series chips report performance cores and efficiency cores individually, but they do not use hyperthreading. An M1 Pro with 8 performance cores and 2 efficiency cores returns 10 entries. The array still represents logical CPUs, but the mapping back to physical hardware is not the same as on a hyperthreaded x86 machine.
The speed field has similar platform wrinkles. On Intel chips with Turbo Boost or AMD chips with Precision Boost, the reported frequency is the base clock, even if the CPU is running much faster under load. A processor may be operating at 4.8 GHz while os.cpus() reports 2400. On Apple Silicon, the reported speed is the performance-core frequency; efficiency cores run slower, but the array still shows the same speed value for every entry. Treat the field as an approximation rather than a live frequency meter.
Calculating CPU Usage
A single os.cpus() snapshot tells you cumulative time since boot. That is enough for totals, but not for answering what the CPU is doing right now. For current utilization, take two snapshots and compare the difference.
function cpuAverage() {
const cpus = os.cpus();
let idle = 0, total = 0;
for (const { times: t } of cpus) {
idle += t.idle;
total += t.user + t.nice + t.sys + t.idle + t.irq;
}
return { idle: idle / cpus.length, total: total / cpus.length };
}That helper collapses all cores into average idle time and average total time. After a delay, subtract the first reading from the second:
const start = cpuAverage();
setTimeout(() => {
const end = cpuAverage();
const idleDiff = end.idle - start.idle;
const totalDiff = end.total - start.total;
console.log(`CPU usage: ${(100 - (idleDiff / totalDiff) * 100).toFixed(1)}%`);
}, 1000);The calculation is simple: total time minus idle time is busy time. Divide busy time by total time, then multiply by 100. A wider sampling interval smooths the result. One second is usually fine for dashboards; five seconds is often better for alerting because it avoids reacting to short spikes that disappear on the next sample.
Sometimes the average hides the problem. A single-threaded workload can pin one core at 100% while the other cores sit idle, producing a low average on a large machine. In that case, keep the per-core entries separate and compute deltas for each one.
function perCoreDelta(prev, curr) {
return curr.map((cpu, i) => {
const p = prev[i].times, c = cpu.times;
const idle = c.idle - p.idle;
const total = (c.user + c.sys + c.idle + c.nice + c.irq) -
(p.user + p.sys + p.idle + p.nice + p.irq);
return ((1 - idle / total) * 100).toFixed(1);
});
}The result is an array of per-core percentages. That view is useful when you are looking for thread pinning, uneven work distribution, or a workload that cannot use the available parallelism.
os.availableParallelism()
os.availableParallelism() was added in Node 19.4 and backported to 18.14. It returns the number of threads the runtime can actually use for parallel work.
os.availableParallelism(); // 4 (inside a container limited to 4 cores)
os.cpus().length; // 64 (the host machine has 64 cores)On bare metal, this number is usually the same as os.cpus().length. Inside containers, the two can diverge. os.cpus().length reports the host machine's logical CPUs, while os.availableParallelism() accounts for cgroup constraints and CPU quota settings that limit the current process.
That difference is important when sizing thread pools and worker counts. A container allocated 2 CPUs should not start 64 CPU-bound workers just because the host has 64 logical CPUs. Those workers would compete for a small quota and spend more time context switching than doing useful work. The same concern applies to libuv's thread pool: the default UV_THREADPOOL_SIZE of 4 is reasonable on machines with several cores, but it can be excessive inside a one-CPU container.

Figure 5.1 — os.cpus() describes the host's logical CPU inventory, while os.availableParallelism() follows the CPU scope that applies to the current process. In containers, quota and affinity can make that split much smaller than the host.
Internally, the function calls libuv's uv_available_parallelism(). On Linux, libuv checks cgroup limits first: /sys/fs/cgroup/cpu.max for cgroups v2, or /sys/fs/cgroup/cpu/cpu.cfs_quota_us for cgroups v1. It can then fall back to sched_getaffinity() and finally sysconf(_SC_NPROCESSORS_ONLN). On macOS, it uses sysconf(_SC_NPROCESSORS_ONLN). On Windows, it uses GetActiveProcessorCount(ALL_PROCESSOR_GROUPS).
The cgroups v2 value is especially direct. /sys/fs/cgroup/cpu.max contains quota period. A container limited to 2 CPUs might show 200000 100000, meaning 200,000 microseconds of CPU time per 100,000 microseconds of wall time. libuv divides quota by period and rounds up. If the quota is max, there is no CPU quota, so libuv falls through to affinity or online CPU count.
System-Level vs Process-Level Memory
os.totalmem() returns total system RAM in bytes. os.freemem() returns how much memory is available.
const total = os.totalmem();
const free = os.freemem();
const usedPct = ((1 - free / total) * 100).toFixed(1);
console.log(`${usedPct}% memory used`);These are system-wide numbers. They are not the same as process.memoryUsage(), which reports memory used by the current Node process: RSS, heap, external memory, and related fields. os.freemem() is about the machine. process.memoryUsage().rss is about this process.
The word "free" needs some care because operating systems count available memory differently. On Linux, os.freemem() reads MemAvailable from /proc/meminfo. That value is the kernel's estimate of how much memory can be made available for new applications after accounting for page cache and reclaimable slab memory. It is more useful than MemFree, which counts only pages that are genuinely unused.
This difference shows up on healthy servers all the time. A Linux machine may report 200 MB of MemFree and 8 GB of MemAvailable because the kernel is using spare RAM as disk cache. That cache can be reclaimed when applications need memory. A health check that alerts on "free memory below 500 MB" needs to know which number it is using, or it will treat normal Linux cache behavior as a failure.
macOS gets the value through the Mach VM subsystem. The system classifies memory as wired, active, inactive, and free. Wired memory is pinned for kernel use, active memory was recently used, inactive memory is cached but reclaimable, and free memory has not been allocated. libuv adds inactive and free pages together because inactive pages can be reused for new allocations. On Windows, GlobalMemoryStatusEx reports ullAvailPhys, which already accounts for reclaimable cache.
os.totalmem() is more predictable. It reports the physical RAM visible to the operating system. On a 16 GB machine, the number will be close to 16 GB, although firmware and hardware reservations can remove a few hundred megabytes from what the OS exposes.
Memory in Containers
The os module is not equally container-aware for every resource. os.availableParallelism() respects CPU limits, but os.totalmem() and os.freemem() report host memory even inside a container with a much smaller memory limit. A container limited to 512 MB may still see os.totalmem() return 64 GB if that is the host's RAM.
Node does have internal awareness of cgroup memory limits, including for the automatic adjustment behind --max-old-space-size. The os module simply does not expose that limit. If you need the container's actual memory ceiling, read /sys/fs/cgroup/memory.max for cgroups v2 or /sys/fs/cgroup/memory/memory.limit_in_bytes for cgroups v1.

Figure 5.2 — Memory readings have different scopes: os.totalmem() and os.freemem() describe host-visible memory, container limits define a smaller operating edge, and process.memoryUsage() reports only the current Node process.
const fs = require('node:fs');
function getContainerMemLimit() {
try {
const raw = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim();
return raw === 'max' ? os.totalmem() : parseInt(raw, 10);
} catch { return os.totalmem(); }
}This tries cgroups v2 first and falls back to total system memory outside a container or on non-Linux platforms. process.constrainedMemory(), added in Node 19.6, is the official API for this. It returns the cgroup memory limit when one is available, or undefined when the process is unconstrained. For production monitoring, compare process.memoryUsage().rss with process.constrainedMemory() when you care about pressure inside a container.
Load Average
os.loadavg(); // [1.34, 2.01, 1.87]The three values are the 1-minute, 5-minute, and 15-minute exponentially weighted moving averages. On Windows, the function always returns [0, 0, 0] because Windows does not expose an equivalent kernel metric.
Load average measures demand on the system's computing resources. It counts processes that are running on a CPU and processes that are runnable but waiting in the run queue. Linux also includes processes in uninterruptible sleep, usually disk I/O waits where a process is blocked in a syscall that cannot be interrupted. Other Unix systems, including FreeBSD and macOS, count runnable processes without that Linux-specific I/O-wait behavior.
Because Linux includes uninterruptible I/O waits, a Linux machine can show a high load average with low CPU usage. The machine is under pressure, but the pressure may be disk I/O rather than CPU execution. The same workload on macOS can show a lower load average because I/O-blocked processes are not included in the same way.
Interpret load average by comparing it with CPU count. On a 4-core machine, a load average of 4.0 means the machine is fully occupied with no average queue depth. A load average of 8.0 means roughly 4 processes are running while 4 more are waiting for CPU time.
const load1m = os.loadavg()[0];
const cpuCount = os.availableParallelism();
const ratio = load1m / cpuCount;A ratio above 1.0 means the run queue is backed up. Around 0.7 is a reasonable point to watch more carefully, while values below 0.3 usually describe an idle or lightly loaded machine. These are guidelines, not universal thresholds. I/O-heavy workloads can tolerate higher load averages because part of the load is blocked on storage rather than consuming CPU cycles.
The three windows tell you whether pressure is new, sustained, or fading. If the 1-minute value is 12.0 and the 15-minute value is 2.0, you are seeing a recent spike. If all three are rising, the pressure is sustained. If the 1-minute value is dropping while the 15-minute value remains high, the spike has passed and the longer window is decaying back down.
Linux updates load average every 5 seconds. Between those updates, the value is stale. The 1-minute average is a decaying exponential with a 1-minute time constant, sampled every 5 seconds: load(t) = load(t-1) * exp(-5/60) + n * (1 - exp(-5/60)), where n is the current number of runnable plus uninterruptible processes. The 5-minute and 15-minute averages use the same formula with different time constants.
macOS calculates load average with similar exponential smoothing, but it uses the Mach scheduler's run queue depth. The sampling interval and decay constants differ slightly from Linux, and the two kernels count different process states. Use load average for trends on the same machine rather than for exact cross-platform comparisons.
Network Interfaces
os.networkInterfaces() returns an object keyed by interface name. Each value is an array of address objects because a single interface can have IPv4 and IPv6 addresses, and sometimes several of each.
const interfaces = os.networkInterfaces();
console.log(Object.keys(interfaces));
// ['lo0', 'en0', 'en1', 'utun0', 'awdl0', 'bridge0']The names come from the operating system. On macOS, en0 is usually the primary Wi-Fi or Ethernet interface, while utun interfaces are VPN tunnels. On Linux, names such as eth0, ens3, wlp2s0, and enp0s25 depend on the kernel's naming scheme. lo0 or lo is loopback. Docker hosts often expose docker0, and container networking commonly creates veth interfaces to connect containers to bridges.
Each address entry has fields like these:
{
address: '192.168.1.42',
netmask: '255.255.255.0',
family: 'IPv4',
mac: 'a4:83:e7:2b:1f:c0',
internal: false,
cidr: '192.168.1.42/24'
}internal is true for loopback addresses such as 127.0.0.1 and ::1. family is either 'IPv4' or 'IPv6'. cidr combines the address with its subnet prefix length. mac is the hardware MAC address as colon-separated hexadecimal. IPv6 link-local addresses can also include scopeid, a numeric identifier for the interface the address belongs to, because fe80:: addresses are scoped to a specific interface.
A common use is finding a non-internal IPv4 address:
function getExternalIPv4() {
const nets = os.networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) return net.address;
}
}
}That function returns the first non-internal IPv4 address it finds. On a simple laptop, that may be the address you expect. On a server with multiple NICs, Docker bridges, VPN tunnels, or bonded interfaces, the first match may not be the LAN address you wanted. The enumeration order comes from the system, so production code often filters by interface name or preferred subnet.
The snapshot also has limits. os.networkInterfaces() returns interfaces that have assigned addresses. A physically connected Ethernet port without a DHCP lease does not appear. Interfaces that are administratively down also do not appear. If a DHCP lease changes after you call the function, your cached result is stale.

Figure 5.3 — os.networkInterfaces() returns a snapshot keyed by interface name. Each key maps to an array because one interface can expose multiple IPv4, IPv6, link-local, VPN, bridge, or container-related address records at the same time.
The mac field has its own traps. Loopback interfaces report '00:00:00:00:00:00', and some virtual interfaces such as tun/tap devices and VPN tunnels also report all zeros. Physical NICs have hardware MAC addresses, but virtual machines and containers receive virtual MACs from the hypervisor or container runtime. Docker generates MAC addresses from a pool based on the container ID, so MAC addresses are a weak basis for hardware fingerprinting or licensing.
IPv6 entries are common even on systems where you did not explicitly configure IPv6. Modern operating systems usually auto-configure link-local IPv6 addresses with the fe80:: prefix on every active interface. These may be derived from the MAC address with EUI-64, or from a random token on systems with privacy addressing. A physical interface often has at least one IPv4 address and one IPv6 link-local address.
Platform and Architecture
Several os functions identify the system. They overlap, but they come from different layers.
os.platform() returns the same value as process.platform: 'linux', 'darwin', 'win32', 'freebsd', 'openbsd', 'sunos', or 'aix'. This is the platform Node was compiled for, stored as a compile-time constant.
os.type() calls uname() on Unix systems and returns the system name. Linux returns 'Linux', macOS returns 'Darwin', and Windows returns 'Windows_NT'. In practice, it agrees with os.platform(), but the vocabulary differs: os.type() returns conventional system names, while os.platform() returns Node's normalized identifiers.
os.arch() returns the CPU architecture that Node was built to target, such as 'x64', 'arm64', 'ia32', 'arm', 's390x', 'ppc64', 'mips', or 'riscv64'. It is the same value as process.arch, and it is also a compile-time constant.
os.machine() was added in Node 18.9. It calls uname() and returns the raw machine hardware name from the kernel, without Node's normalization. On Apple Silicon, both os.arch() and os.machine() return 'arm64'. On x86_64 Linux, os.arch() returns 'x64', while os.machine() returns 'x86_64'.
console.log(os.platform()); // 'darwin'
console.log(os.type()); // 'Darwin'
console.log(os.arch()); // 'arm64'
console.log(os.machine()); // 'arm64'os.release() returns the kernel version string. On Linux, it might look like '5.15.0-76-generic'. On macOS, it returns the Darwin kernel version, such as '23.1.0', not the macOS marketing version like "Sonoma 14.1". On Windows, it returns a string such as '10.0.22621'.
os.version(), added in Node 13, returns a fuller OS version string. On macOS, it can include the Darwin kernel build line. On Linux, it includes the kernel build number and related build text, but not the distribution name; distribution information lives in files such as /etc/os-release. On Windows, os.version() returns a marketing-oriented string such as 'Windows 10 Pro' or 'Windows 11 Home', which libuv extracts from the Windows Registry.
That Windows difference is important because os.release() returns values like '10.0.22621' for both Windows 10 and Windows 11. Microsoft kept the internal major version at 10.0; Windows 11 is identified by build numbers 22000 and above. If you need a user-facing name, os.version() is more useful than os.release().
These values usually show up in conditional code paths, platform-specific native binary loading, startup diagnostics, user-agent strings, and CLI tools that behave differently on Windows and Unix.
const binPath = `./vendor/${os.platform()}-${os.arch()}/tool`;That pattern, combining platform and architecture into a path segment, is how tools such as esbuild and swc distribute prebuilt binaries. Each platform-architecture combination gets its own package, directory, or optional dependency, such as esbuild-darwin-arm64 or esbuild-linux-x64.
System Paths and Identity
os.hostname() returns the system hostname. On most Unix systems, it matches the hostname command. On cloud instances, it may be an instance ID or a generated name such as ip-10-0-1-43.ec2.internal. In Kubernetes pods, it defaults to the pod name.
os.hostname(); // 'macbook-pro.local'The implementation calls uv_os_gethostname(), which wraps gethostname() on Unix and GetComputerNameExW() on Windows. The hostname can change while the process is running, although that is uncommon, and os.hostname() returns the current value each time.
os.homedir() returns the current user's home directory. On Unix, it reads the HOME environment variable first and falls back to the password database entry through getpwuid_r(). On Windows, it reads USERPROFILE first and falls back to HOMEDRIVE plus HOMEPATH. The environment variable wins, so changing HOME changes what os.homedir() returns.
os.tmpdir() returns the default directory for temporary files. On Unix, it checks TMPDIR, TMP, and TEMP in that order, then defaults to /tmp. On Windows, it checks TEMP and TMP, then falls back to %SystemRoot%\temp or %windir%\temp.
os.homedir(); // '/Users/ishtmeet'
os.tmpdir(); // '/tmp'Both functions respect runtime configuration. If a CI system starts your process with TMPDIR=/scratch, os.tmpdir() returns /scratch. Code that hardcodes /tmp fails in those environments, so portable temporary-file code should use os.tmpdir().
macOS is a common example of this behavior. Even though /tmp exists, os.tmpdir() often returns a per-user path under /var/folders/.../T/ because macOS sets TMPDIR for each user session.
User Information
const info = os.userInfo();os.userInfo() returns uid, gid, username, homedir, and shell. On Unix, it calls getpwuid_r() with the effective UID of the process. The _r suffix is important because this is the reentrant, thread-safe password database lookup.
{
uid: 501,
gid: 20,
username: 'ishtmeet',
homedir: '/Users/ishtmeet',
shell: '/bin/zsh'
}The homedir value here does not come from the same place as os.homedir(). os.userInfo().homedir comes from the password database or directory service. os.homedir() checks the environment first. If a user runs HOME=/custom node app.js, os.homedir() returns /custom, while os.userInfo().homedir still returns the system-configured home directory. For most application code, os.homedir() is the better choice because it respects runtime configuration.
On Windows, uid and gid are both -1 because Windows uses SIDs rather than Unix-style numeric IDs. username comes from the GetUserNameW Win32 API, and shell is null.
The encoding option controls the encoding of string fields. The default is 'utf8'. Passing { encoding: 'buffer' } returns Buffer instances instead of strings, which is useful if a username or home path contains bytes that are not valid UTF-8. That is rare on modern systems, but it can happen with legacy locale settings or non-UTF-8 filesystem encodings.
Line Endings and Endianness
os.EOL is '\n' on Unix and '\r\n' on Windows. It is a constant value.
const lines = ['first line', 'second line', 'third line'];
const output = lines.join(os.EOL);For files that will be read on the same platform, os.EOL follows the platform convention. For files that move across platforms, such as JSON, YAML, and configuration files committed to git, an explicit '\n' is usually better. Git's core.autocrlf can handle checkout conversion, and most parsers and editors accept '\n' everywhere. os.EOL is most useful for console output and log files that platform tools may parse.
os.endianness() returns 'LE' or 'BE'. x86, x64, and ARM in standard mode are little-endian. Big-endian systems are rare in current server-side JavaScript work: some IBM POWER configurations, some MIPS variants, and embedded architectures still use them. You may care about host endianness when implementing binary protocols or file formats, but in practice you usually choose byte order explicitly with methods such as Buffer.readInt32BE() and Buffer.readInt32LE() because the protocol defines the byte order, not the host.
System Uptime
os.uptime(); // 847293os.uptime() returns system uptime in seconds: how long the host has been running since its last boot. The value belongs to the machine, not the current program. A server that has been up for 10 days reports around 864,000 seconds. process.uptime() reports the current Node process's uptime, which is usually much shorter.
On Linux, libuv reads /proc/uptime; the first number in that file is seconds since boot. On macOS, it uses sysctl with KERN_BOOTTIME and subtracts that boot time from the current time. On Windows, GetTickCount64() returns milliseconds since boot, and libuv divides by 1000.
System uptime is useful for detecting recent host restarts. If a health check sees os.uptime() under 300 seconds, the machine probably rebooted recently. In containers, however, os.uptime() reports the host's uptime. For container uptime, use process.uptime() when the Node process starts with the container, or read /proc/1/stat for the container init process start time.
Constants
os.constants has two primary sub-objects: signals and errno. They map readable names to numeric values.
os.constants.signals.SIGTERM; // 15
os.constants.signals.SIGKILL; // 9
os.constants.signals.SIGINT; // 2The signals object contains the POSIX signals supported by the platform. Linux exposes roughly 31 standard signals plus real-time signals up to 64. macOS exposes about 31 standard signals. Windows has meaningful support for only a small set such as SIGINT, SIGTERM, SIGKILL, and SIGBREAK; other constants may exist, but the operating system will not deliver them in the same POSIX sense. Signal handling was covered in the previous subchapter, and os.constants.signals is the programmatic way to refer to the numeric values.
os.constants.errno.ENOENT; // -2
os.constants.errno.EACCES; // -13
os.constants.errno.EADDRINUSE; // -98 (Linux) or -48 (macOS)Errno values are platform-dependent. EADDRINUSE is -98 on Linux and -48 on macOS. These values come through libuv's normalization, where libuv negates POSIX errno values and provides its own mapping. Cross-platform application code should usually check err.code strings such as 'EADDRINUSE' instead of hardcoding numbers. Raw numeric constants are mainly useful when working with libuv return values in native addons.
os.constants.priority defines process priority levels:
os.constants.priority.PRIORITY_LOW; // 19
os.constants.priority.PRIORITY_BELOW_NORMAL; // 10
os.constants.priority.PRIORITY_NORMAL; // 0
os.constants.priority.PRIORITY_HIGH; // -14Those constants connect directly to os.getPriority() and os.setPriority().
Process Priority
os.getPriority() and os.setPriority() read and change process scheduling priority.
os.getPriority(); // 0 (normal priority for current process)
os.getPriority(1234); // get priority of process 1234With no argument, or with 0, os.getPriority() queries the current process. With a PID, it queries another process. The return value is a Unix nice value: 0 is normal, positive values lower priority up to 19, and negative values raise priority down to -20. Higher-priority processes receive more CPU time from the scheduler.
os.setPriority(os.constants.priority.PRIORITY_LOW);Setting priority to PRIORITY_LOW (19) tells the scheduler to prefer other processes over yours. That fits background workers, batch jobs, and maintenance scripts where latency is not important. Setting PRIORITY_HIGH (-14) gives the process preference, but it requires root or administrator privileges.
On Linux, these calls map to the setpriority() and getpriority() syscalls. Unprivileged users can raise the nice value, which lowers their own priority. Lowering the nice value, which raises priority, requires CAP_SYS_NICE or root. On Windows, the priority constants map to Windows priority classes such as IDLE_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, NORMAL_PRIORITY_CLASS, ABOVE_NORMAL_PRIORITY_CLASS, and HIGH_PRIORITY_CLASS.
Handle errors when changing priority. os.setPriority(pid, priority) throws an ERR_SYSTEM_ERROR with errno EACCES or EPERM when the process lacks permission.
The use case is narrow but real. If a background job processor runs on the same machine as a latency-sensitive HTTP server, the worker can be set to PRIORITY_BELOW_NORMAL so it yields CPU time first. In containerized deployments with one workload per container, this is less relevant because the scheduler and resource limits isolate the workloads. On shared VMs or bare metal with mixed workloads, priority is still a useful tuning knob.
How CPU and Memory Calls Reach the System
Every os module function maps to a libuv function, and libuv maps that call to platform-specific kernel interfaces. The CPU path shows how much work is hidden behind a small JavaScript object.
When you call os.cpus(), Node's C++ binding invokes uv_cpu_info(). libuv allocates an array of uv_cpu_info_t structs and fills it differently on each platform.
Linux. libuv opens /proc/stat and parses the per-CPU lines. A line looks like this:
cpu0 10132153 290696 3084719 46828483 16683 0 25195 0 0 0The fields are, in order, user, nice, system, idle, iowait, irq, softirq, steal, guest, and guest_nice. They are measured in clock ticks, also called jiffies. One jiffy is often 10 ms on kernels built with CONFIG_HZ=100, but some distributions use CONFIG_HZ=250 or CONFIG_HZ=1000. libuv normalizes the values to milliseconds by dividing by sysconf(_SC_CLK_TCK) and multiplying by 1000. The CPU model comes from /proc/cpuinfo, specifically the model name field, and the speed comes from the cpu MHz field.
The iowait field counts time when the CPU was idle while outstanding I/O requests existed. libuv folds that value into idle in the object returned to JavaScript. steal measures time a hypervisor assigned this virtual CPU to another VM, which is significant in cloud environments. guest and guest_nice count time spent running a guest VM; kernel accounting folds those into user and nice.
On 32-bit systems, the time counters can wrap. Linux stores per-CPU jiffies as unsigned long, which is 32 bits on a 32-bit system. At CONFIG_HZ=100, the counter overflows after roughly 497 days. After that, delta calculations can produce negative values or impossible CPU percentages. On 64-bit systems, the counters are large enough that this is not a practical concern. If you run Node on 32-bit embedded devices or IoT gateways with long uptimes, account for wraparound.
macOS. libuv uses Mach host_processor_info() with the PROCESSOR_CPU_LOAD_INFO flavor. The call returns an array of processor_cpu_load_info structs, one per logical CPU. Each struct has a cpu_ticks array with CPU_STATE_USER, CPU_STATE_SYSTEM, CPU_STATE_IDLE, and CPU_STATE_NICE. macOS folds IRQ time into system time. libuv converts the tick values to milliseconds using mach_timebase_info().
For model and speed, libuv reads sysctl values. machdep.cpu.brand_string gives the brand string, and hw.cpufrequency provides the clock speed in Hz. On Apple Silicon, hw.cpufrequency reports the performance-core frequency. Efficiency cores run at a different frequency, but os.cpus() reports the same speed for every core because this API does not distinguish P-cores from E-cores.
Windows. libuv calls GetSystemInfo() to determine processor count, then calls NtQuerySystemInformation() with SystemProcessorPerformanceInformation for per-CPU timing data. The time values are returned as 100-nanosecond FILETIME intervals. CPU model information comes from HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0\ProcessorNameString, and speed comes from the ~MHz DWORD value in the same registry key.
Memory has the same cross-platform shape. os.freemem() calls libuv's uv_get_free_memory(), and the implementation depends on the host.
Linux. libuv opens /proc/meminfo and parses MemAvailable. Older kernels before 3.14, released in 2014, do not expose MemAvailable, so libuv falls back to MemFree. The difference is important because MemFree counts only completely unused pages. MemAvailable estimates how much memory can be allocated without swapping after considering page cache, reclaimable slab caches, and low watermark thresholds. os.totalmem() calls uv_get_total_memory(), which reads MemTotal from the same file.
macOS. Free memory comes from host_statistics64() with the HOST_VM_INFO64 flavor, which fills a vm_statistics64_data_t structure. libuv adds free_count and inactive_count, then multiplies by vm_page_size, commonly 16384 bytes on Apple Silicon and 4096 bytes on Intel Macs. Total memory comes from sysctl with HW_MEMSIZE.
The macOS memory model explains why inactive pages are included. macOS categorizes pages as wired, active, inactive, speculative, and free. Inactive pages are still in RAM but have not been accessed recently, so they can be reclaimed. A busy Mac may show a low raw free count while still having plenty of reclaimable inactive memory.
Windows. GlobalMemoryStatusEx() fills a MEMORYSTATUSEX structure. ullAvailPhys is available physical memory, and ullTotalPhys is total physical memory. Windows also caches disk data aggressively, and its available-memory number already accounts for reclaimable cache.
There is also uv_get_constrained_memory(), which backs process.constrainedMemory() in Node 19.6 and newer. In containers, it reads the cgroup memory limit from memory.max in cgroups v2 or memory.limit_in_bytes in cgroups v1. The os module does not expose that function, so container-aware memory checks should use process.constrainedMemory() or read the cgroup files directly.
Practical Patterns
A monitoring endpoint often starts with a compact system health object:
function systemHealth() {
const cpuCount = os.availableParallelism();
const load1m = os.loadavg()[0];
const freeGb = os.freemem() / 1073741824;
const totalGb = os.totalmem() / 1073741824;
return { cpuCount, loadPerCpu: load1m / cpuCount, freeGb, totalGb };
}For platform-conditional logic, compute the result once and reuse it. os.platform() is effectively constant, and caching also keeps hot paths readable.
const isWindows = os.platform() === 'win32';
const isMac = os.platform() === 'darwin';
const isLinux = os.platform() === 'linux';For service registration or discovery, network interfaces often provide the address a process should report:
function getServiceAddress() {
for (const [n, addrs] of Object.entries(os.networkInterfaces())) {
const v4 = addrs.find(a => a.family === 'IPv4' && !a.internal);
if (v4) return { interface: n, address: v4.address, cidr: v4.cidr };
}
return null;
}In Kubernetes, the pod IP is often the address an HTTP server binds to or reports. Service mesh sidecars such as Envoy and Linkerd also need to know the pod IP for proxying. os.networkInterfaces() lets the process discover that address at runtime instead of hardcoding it or depending only on environment variables.
Startup Logging
Printing a small system summary at startup is useful when diagnosing production failures.
console.log({
hostname: os.hostname(),
platform: `${os.platform()}-${os.arch()}`,
cpus: os.availableParallelism(),
totalMemMb: Math.round(os.totalmem() / 1048576),
nodeVersion: process.version,
uptime: Math.round(os.uptime() / 3600) + 'h',
});When you are reading logs later, the difference between a 2-CPU, 512 MB container and a 64-CPU, 256 GB bare-metal machine changes the diagnosis. A few startup fields often explain behavior that would otherwise look mysterious.
What the os Module Doesn't Cover
The os module stays focused on machine-level introspection. Process-level memory breakdown belongs to process.memoryUsage(). Process uptime is process.uptime(). Environment variables live on process.env.
Disk information is absent. Node does not provide os.diskfree() or os.disks(). For filesystem capacity, fs.statfs(), added in Node 18.15, returns total bytes, free bytes, and available bytes for the filesystem containing a path. You still need to know which path you care about; built-in APIs do not enumerate every mounted filesystem for you.
GPU introspection also lives outside the module. Node has no built-in GPU API, so VRAM usage, GPU temperature, and GPU utilization come from native addons, child process calls to tools such as nvidia-smi, or platform-specific APIs.
Battery information is missing as well. There is no os.battery() for laptop or UPS charge level. On Linux, battery data lives under /sys/class/power_supply/. On macOS, IOKit provides it. On Windows, GetSystemPowerStatus() exposes it. libuv does not wrap these APIs, so Node does not expose them through os.
Temperature sensors, fan speeds, and other hardware monitoring data are similarly out of scope. The module provides what libuv wraps, and libuv focuses on primitives needed by I/O-driven server software: CPU, memory, network, and platform identity.
Process listing is another gap. Node does not ship os.processes() for enumerating running processes. On Linux, you would scan numeric directories under /proc/. On macOS, sysctl with KERN_PROC gives access to the process table. On Windows, CreateToolhelp32Snapshot() enumerates processes. Packages such as ps-list wrap those platform-specific approaches, but there is no built-in API.
Almost all of the module is read-only. The exception is os.setPriority(), which can modify scheduling priority. You cannot change the hostname, add network interfaces, adjust memory limits, or modify kernel parameters through os. Those operations require privileged commands such as hostname, ip, or sysctl, or native addons that call the relevant system APIs.