CLI Flags and Runtime Configuration
Node starts configuring itself before your entry file exists as a JavaScript module.
That sounds obvious. It still trips people up. Flags like --max-old-space-size, --inspect, --env-file, --require, and --conditions are consumed while the executable is still in native startup code. By the time your main.js runs, Node has already decided how much heap V8 may use, which modules get preloaded, which source-map behavior applies, whether the inspector is listening, which conditions participate in package resolution, and which arguments belong to your application.
The command line is part of the runtime contract. Treat it that way.
node --enable-source-maps --conditions=dev app.js --port 3000The first two flags belong to Node. app.js becomes the program entry. --port 3000 belongs to your program. Node preserves that split in process.execArgv and process.argv, both covered in Chapter 5, so user code can see the distinction after startup.
console.log(process.execArgv);
console.log(process.argv.slice(2));Run that with the previous command. process.execArgv contains --enable-source-maps and --conditions=dev. The application arguments contain --port and 3000. Node already consumed the runtime flags before the JavaScript entry point started evaluating.
The Runtime Has Several Inputs
Runtime configuration reaches Node through more than one channel. The executable receives argv from the operating system. The environment contributes variables such as NODE_OPTIONS, NODE_EXTRA_CA_CERTS, and UV_THREADPOOL_SIZE. --env-file can add environment variables from a file before user code starts. v24 also has an experimental JSON config file path, which lets a project record supported options in a structured document.
The precedence matters.
Node v24 documents config priority in tiers: direct NODE_OPTIONS and command-line options sit above the configuration file, and the configuration file sits above NODE_OPTIONS loaded from dotenv files. For direct NODE_OPTIONS versus the command line, singleton flags take the last value. Repeatable flags accumulate in source order. Put project defaults in a config file. Put deployment defaults in real environment variables. Put one-off overrides on the actual command line.
NODE_OPTIONS='--max-old-space-size=2048' \
node --max-old-space-size=4096 app.jsV8 receives 4096 here. The command line wins for a singleton option. NODE_OPTIONS still appears in the startup path, but the later explicit flag replaces the earlier value.
Repeatable options behave differently.
NODE_OPTIONS='--require ./a.cjs' \
node --require ./b.cjs app.jsBoth preload modules run. The environment-provided preload runs before the command-line preload. That ordering matters when the first module mutates globals, installs hooks, registers diagnostics, or patches module loading. Better yet, keep preloads small and boring. Startup state is shared process state.
The -- marker gives user arguments a hard boundary.
node --enable-source-maps app.js -- --trace-warningsEverything after -- belongs to your application, including strings that look like Node flags. Your parser sees --trace-warnings. Node treats it as data for the program.
NODE_OPTIONS
NODE_OPTIONS is the environment variable for passing Node flags without editing the command that starts the process. Shell profiles use it. Process managers use it. Containers use it. CI systems use it. A platform team can set diagnostics or memory limits across many services through one environment value.
export NODE_OPTIONS='--enable-source-maps --trace-warnings'
node server.jsNode parses that string before the command-line arguments. The parser accepts spaces as separators and double quotes for values containing spaces. It applies an allowlist. Flags that make sense only on the real command line, such as --help, --version, --eval, --print, and script selection forms, are rejected from NODE_OPTIONS.
NODE_OPTIONS='--eval "console.log(1)"' node app.jsThat fails during startup. The rejection happens before your script loads. The allowlist is deliberate because environment variables are often inherited from a parent process, a process manager, a shell session, or a CI job. Node accepts configuration flags through NODE_OPTIONS; it rejects flags that would replace the program being executed.
There is a quoting rule worth knowing. NODE_OPTIONS has its own small parser in native code. It handles double quotes and backslash escapes inside quoted text. It does basic tokenization. It is much smaller than a shell parser. Shell expansion already happened before Node sees the environment. Node receives a plain string, then splits it into option tokens.
NODE_OPTIONS='--require "./setup file.cjs"' node app.jsThe quoted path stays one argument in Node's parser. Single quotes are a shell feature in that example; Node sees the string after the shell removes the outer quotes. Inside the actual variable value, Node recognizes the double quotes around the filename.
Operationally, NODE_OPTIONS is useful and dangerous in the same way any inherited environment value is useful and dangerous. A flag set at the shell level affects every child node process from that shell. A flag set by a process manager affects every service under that manager unless overridden. A memory cap meant for a CLI tool can leak into a server. A preload meant for tests can run in production.
Keep it visible. Log process.execArgv beside process.env.NODE_OPTIONS during startup when you are diagnosing odd runtime behavior. A service that suddenly starts exposing an inspector port or producing warning traces often inherited a flag from outside the repository.
Native Startup Parsing
The native startup path is where the runtime contract becomes concrete. Node's Start() receives the platform argc and argv, applies single-executable fixups when that build mode is active, then calls the internal startup routine. Early code calls uv_setup_args(), because libuv needs to normalize the argument vector on some platforms and because Node later supports process.title mutation by keeping access to an adjustable copy.
Then the one-time process initialization path runs. It records startup time, sets up stdio behavior, resets or installs default signal handlers, raises file descriptor limits on POSIX where possible, and starts parsing Node options before V8 is initialized. That ordering is the point. Options can affect V8 initialization, OpenSSL initialization, inspector setup, the Node platform thread pool, and snapshot loading. JavaScript has no chance to change those decisions after the isolate exists.
The parser itself lives around node_options.cc and node_options.h. The code registers every supported option with a typed field. Boolean flags map to bool members. Numeric flags map to integer members. String flags map to std::string. Repeatable flags map to std::vector<std::string>. Host-port options use a small HostPort structure. The parser also records whether a given option is allowed in NODE_OPTIONS, whether it belongs to a namespace such as test runner or watch mode, and which aliases expand into other options.
That registration table is the source of truth for the CLI. Documentation follows it. Help output follows it. The internal node:internal/options binding exposes enough metadata for JavaScript-side helpers to inspect option values and generate config schemas. When Node adds a flag, it gets registered with a type, storage location, help text, and environment-variable policy.
Parsing mutates three collections. One collection becomes the remaining program arguments. One collection becomes exec_args, exposed later as process.execArgv. One collection becomes V8 arguments, passed through V8::SetFlagsFromCommandLine() or equivalent startup paths. Unknown V8-style flags can travel to V8 while Node-specific flags populate Node's own option structs. Unknown user arguments after the script boundary stay with the program.
Options are split by lifetime. PerProcessOptions holds process-wide settings: title, OpenSSL CA store choice, FIPS mode, report output paths, large pages, V8 platform thread-pool size, snapshot flags, and the --run task name. PerIsolateOptions holds settings tied to a V8 isolate: heap tracking, heap size, stack trace limit, snapshot build settings. EnvironmentOptions holds settings attached to a Node environment: source maps, preloads, permission flags, test runner options, watch mode, module conditions, TLS defaults, warning behavior, TypeScript stripping, and the actual application argument tail.
That separation shows up later with worker threads and embedders. A process has one OpenSSL initialization decision. A V8 isolate has heap-related settings. A Node environment has module-loading and execution settings. Main-thread Node usually creates one of each, so the difference feels academic until workers, snapshots, or embedding enters the picture.
After parsing, CheckOptions() methods validate combinations. Some flags conflict because they select different entry modes. --check and --eval each want to define the main operation, so the validator rejects the pair. --watch conflicts with syntax checking and direct eval. Test runner settings reject watch-path combinations outside the test runner path. TLS min and max combinations get checked. Invalid enum values, invalid host-port values, and dependent flags produce startup errors.
Only after this early validation does Node initialize larger subsystems. OpenSSL configuration can read OPENSSL_CONF or --openssl-config. FIPS flags affect crypto setup. The V8 platform starts with its configured thread-pool size. V8 receives its flags before initialization. Heap limits and stack trace limits have to be available before user code exists. Diagnostics flags install hooks and profilers around the environment startup path.
Finally, Node creates the main instance and chooses an internal main script. That selection is still driven by parsed options. Eval code goes through internal/main/eval_string. Syntax checking goes through internal/main/check_syntax. The test runner has its own main. Watch mode has its own main. A normal script uses internal/main/run_main_module. A terminal with no script starts the REPL. Piped stdin uses eval-stdin. The choice happens after option parsing and before your code runs.
V8 Flags And Pass-Through
Node owns many flags, but it also forwards engine flags to V8. That makes the command line a shared surface between the JavaScript engine and the Node runtime. A flag such as --max-old-space-size is known to Node because Node exposes it in its help output and validation path, then hands the value to V8 before isolate creation. Other engine flags are printed through --v8-options.
node --v8-options | rg stack-trace-limitThat command asks V8 for its supported flag list. The exact list depends on the V8 version bundled with the Node binary. Node v24 and Node v25 can differ because the embedded engine differs. Treat V8 flags as version-coupled runtime settings.
Some V8 flags also have Node-visible behavior. --abort-on-uncaught-exception reaches V8 and Node internals because both layers need to agree on failure behavior. --stack-trace-limit affects stack formatting visible from JavaScript. Heap flags affect GC behavior and memory limits. The parser tracks special cases where a V8-looking flag needs a Node-side field as well.
Pass-through has a practical consequence: a flag accepted by one Node binary can fail under another. The same deployment command can work in a developer shell and fail in a container built from a different base image. When you use engine flags beyond the common memory settings, pin the Node release and check node --v8-options in the same image that runs production.
The parser's split into exec_args and v8_args also explains why process.execArgv is useful but incomplete as a low-level engine record. It shows Node execution arguments spelled on the direct command line. NODE_OPTIONS, config-file values, aliases, implications, and V8 parsing can still affect settled runtime state. For application diagnostics, pair process.execArgv with the raw environment and the launch definition. For engine experiments, use the exact command and the exact Node binary as the reproducible artifact.
Boolean negation uses the --no- form for many flags whose default is enabled.
node --no-warnings app.js
node --no-global-search-paths app.jsThe option table knows whether a boolean defaults to true. When Node serializes option values internally, it can produce the positive or negated form. That matters for config-file tooling and for child process propagation, because a false value sometimes needs to be represented as an explicit --no-* flag rather than omission.
Aliases add another wrinkle. -r expands to --require. -C expands to --conditions. --debug-port maps to the inspector port option. Some aliases expand into more than one internal flag. The parser stores aliases as expansions, then parses the resulting option stream. So the spelling you type and the field that changes inside EnvironmentOptions can differ.
node -r ./setup.cjs -C local app.jsThat is the short spelling for a preload plus a user condition. In process.execArgv, you usually see the spelling supplied by the user. In internal option storage, Node sees the canonical fields.
Config Sources In Practice
A modern service can easily have four places that influence Node startup: the package script, the process-manager manifest, environment variables, and an env file. v24's experimental JSON config file adds a fifth.
Package scripts are good for developer commands.
{
"scripts": {
"dev": "node --watch --enable-source-maps src/server.js"
}
}That script says what a developer gets when they run the local dev command. It can use watch mode, source maps, local preloads, or test flags. It should avoid production-only memory sizing because developer machines and containers usually have different memory profiles.
Process-manager manifests are good for production runtime policy.
node --max-old-space-size=2048 --report-on-fatalerror dist/server.jsPut that command in the service definition, container command array, or platform launch configuration. The deployment owner can review it. Incident responders can find it. Runtime flags become part of the service spec rather than lore stored in a shell profile.
Environment variables are good for values supplied by the platform.
NODE_OPTIONS='--enable-source-maps' node dist/server.jsThat pattern works when the platform owns a default across many services. Source maps, warning policy, or CA settings may live there. Keep the scope tight. A globally inherited NODE_OPTIONS value is hard to reason about because every nested Node process receives it unless the launcher clears the variable.
Env files are good for local and per-environment key-value data.
node --env-file=.env.local src/server.jsThat keeps app-facing environment values out of the command itself. Because env files can also feed NODE_OPTIONS, a team should decide whether that is allowed. Some projects ban Node runtime flags inside env files and reserve them for launch definitions. Others allow them so a checked-in .env.development can turn on source maps and conditions. Either policy is fine when it is explicit.
The experimental JSON config file is good for repository-level Node defaults.
{
"$schema": "https://nodejs.org/dist/v24.15.0/docs/node-config-schema.json",
"nodeOptions": {
"conditions": ["development"],
"enable-source-maps": true
}
}That shape records Node-owned settings as structured data. The schema URL should match the Node line you target. Because the feature is experimental in v24, schema and accepted options can move.
The useful split is boring: checked-in config defines project defaults, deployment environment defines platform defaults, direct command line handles local override or incident override. Any source can technically do more. Clear ownership keeps the final process explainable.
Source Precedence With Examples
Precedence is easier to remember through concrete collisions. Start with a config file that enables source maps and sets a condition.
{
"nodeOptions": {
"conditions": ["project"],
"enable-source-maps": true
}
}Run Node with a config default and a command-line addition.
node --experimental-config-file=node.config.json \
--conditions=cli app.jsBoth condition values can participate because --conditions is repeatable. The project config contributes project, and the command contributes cli. Resolution sees both user conditions after Node builds the final set.
Singletons replace instead.
NODE_OPTIONS='--unhandled-rejections=warn' \
node --unhandled-rejections=strict app.jsThe final unhandled rejection mode is strict. The later command-line singleton wins. If the same singleton appears twice on the command line, the later spelling wins there too.
Boolean defaults need explicit negation.
NODE_OPTIONS='--no-warnings' node --trace-warnings app.jsThe warning system receives both settings. One disables warnings, the other requests traces for warnings. Valid combinations still depend on each option's semantics after parsing. Avoid contradictory pairs in launch files. They make later debugging waste time because the command itself has no single intent.
Env-file ordering deserves care. A file can supply NODE_OPTIONS, and direct command-line options still override singleton values from that source. A JSON config file also outranks NODE_OPTIONS that came from dotenv.
node --env-file=.runtime.env \
--max-old-space-size=4096 app.jsIf .runtime.env contains NODE_OPTIONS=--max-old-space-size=2048, the command line still sets the final old-space value to 4096. The file contributes environment values early, then Node parses its runtime flags with dotenv-sourced options at the lowest config tier.
One more detail matters for repeatable preloads. Order is observable.
NODE_OPTIONS='--import ./env.mjs' \
node --import ./cli.mjs app.mjsenv.mjs runs before cli.mjs. If env.mjs registers a loader, patches a global, or sets process state, cli.mjs sees that state. When preloads need ordering, encode the order in one preload module and keep the launch command shorter.
Flag Categories
Most runtime flags fall into a few groups. Sorting them that way helps during reviews.
Execution-mode flags choose what Node runs. --eval, --print, --check, --test, --watch, --run, and normal script execution all select a main path. These flags belong near the command that a human or process manager invokes.
Engine and memory flags configure V8 or allocation behavior. --max-old-space-size, --max-old-space-size-percentage, --stack-trace-limit, --jitless, --expose-gc, and --zero-fill-buffers sit here. They affect performance, memory ceiling, or observability of engine state.
Module flags configure how code loads. --require, --import, --experimental-loader, --conditions, --preserve-symlinks, --input-type, and TypeScript stripping flags live here. They can change the module graph before application code starts.
Diagnostics flags configure output and capture. Warning flags, report flags, trace events, CPU profiles, heap profiles, heap snapshots, source maps, and sync-I/O tracing live here. They produce data or change error reporting.
Security and policy flags configure process permissions or platform trust. Permission-model flags, addon controls, TLS bounds, CA store selection, FIPS flags, OpenSSL config, and proxy-from-environment settings live here. They change what the process may access or which native library policy applies.
Development tooling flags configure feedback loops. Watch mode, inspector, test runner, snapshots, single executable application build flags, and REPL behavior live here. Some are safe in production under controlled use, but most are easier to reason about when scoped to development or release tooling.
That categorization gives code reviewers a quick smell test. A production launch command with memory, source maps, and report flags makes sense. The same command with watch mode, an inspector bind to all interfaces, a loader from a relative path, and a test-only preload needs scrutiny.
Wrapper Commands
Many Node processes start through another tool. npm run, pnpm, yarn, Docker entrypoints, shell scripts, systemd units, process managers, and test runners all build an argument vector before Node sees it. The wrapper decides quoting, environment inheritance, current working directory, and sometimes signal behavior.
Prefer argument arrays when the platform supports them.
{
"cmd": ["node", "--enable-source-maps", "dist/server.js"]
}An array keeps token boundaries explicit. A shell string has another parsing layer before Node receives argv. Quoting bugs usually come from that extra layer, especially when paths contain spaces or when a wrapper injects NODE_OPTIONS.
Package scripts always run through the package manager's script environment. That environment may add node_modules/.bin to PATH, set npm-specific variables, and alter the current working directory. A command that works as npm run start can differ from node dist/server.js because the surrounding environment differs.
Docker has the same split between shell form and exec form.
CMD ["node", "--enable-source-maps", "dist/server.js"]Exec form passes arguments directly to the process. Shell form runs through /bin/sh -c, adding shell expansion and signal-forwarding differences. For Node services, exec form usually gives clearer runtime configuration and cleaner signal handling.
Systemd units and process managers add their own environment files and restart behavior. If a service uses EnvironmentFile=, NODE_OPTIONS can arrive from there. If it uses a wrapper script, the script may reorder arguments. During incident review, inspect the final launch definition, not just package.json.
The general rule is to debug the final argv and environment. Node can only parse the data it receives. Wrapper layers are outside Node, but they decide the bytes that reach Node's parser.
Entry Modes
The simplest Node command names a file.
node app.jsNode resolves app.js as the main entry and starts the module loading path. CommonJS and ES module rules still apply. The module system details are covered in Chapter 6; the CLI only decides which string becomes the entry request.
The -e and --eval flags replace the file entry with source text supplied on the command line.
node --input-type=module -e "import fs from 'node:fs'; console.log(fs)"--input-type tells Node how to parse string input. For v24 it accepts CommonJS, ES module, and TypeScript-flavored variants for string input. That option matters for --eval, --print, and stdin, because a raw string has no extension and no surrounding package.json boundary to infer module type.
-p and --print evaluate source and print the result. The flag is useful for shell probes, build scripts, and checking expressions against the current Node version.
node -p "process.versions.node"That command still performs runtime startup. It parses options, starts V8, creates an environment, evaluates the string, prints the result, and exits.
--check performs syntax checking. It parses the target file and exits after reporting syntax errors.
node --check server.jsSyntax checking is a parser operation, so Node parses the file and exits before evaluation. The source still has to parse under the module mode Node chooses for that file. Ambiguous .js files can involve package type, detected module syntax, or CLI options that affect detection.
--run executes a script from package.json. In v24, it is a Node CLI feature rather than an npm command.
node --run testNode reads the package task and spawns the configured command through its task runner path. That keeps the command under the Node executable, which is useful in environments where you want one toolchain entry point. It also means node --run has Node's CLI parsing ahead of the task lookup.
Watch mode and the test runner are also entry modes. They get their own subchapter later in this runtime platform chapter, so keep the mental model narrow here: a flag can select a different internal main script. The public command still says node; the internal main changes.
Memory Flags
Memory flags are the ones teams reach for first when a service hits heap pressure. The common one is V8's old-space cap.
node --max-old-space-size=4096 server.jsThe value is megabytes. It sets V8's maximum old-generation heap size. The term old space was covered in the V8 discussion earlier, so the runtime angle is simple: this flag goes to V8 during startup, before the isolate is usable. Changing it from JavaScript after startup is the wrong layer; the heap is already configured.
v24 also supports --max-old-space-size-percentage.
node --max-old-space-size-percentage=75 server.jsNode computes the heap cap from available memory. On systems with constrained memory, such as containers, it uses libuv's constrained-memory value when available; otherwise it uses total system memory. With V8 pointer compression, the calculation respects the smaller practical heap ceiling. The parser validates the percentage and converts it into a megabyte value before V8 receives the final setting.
The percentage form fits containers better than a fixed megabyte value when the same image runs with different memory limits. A service running with 512 MB and the same service running with 4 GB should usually have different heap caps. The flag lets the platform set a policy rather than a number.
Heap snapshot flags sit nearby but solve a different operational problem.
node --heapsnapshot-near-heap-limit=3 server.jsThat asks Node to write heap snapshots as V8 approaches its heap limit, up to the given count. The snapshots can be large. They can expose object data. They can slow the process at the exact moment memory pressure is already high. Use the flag deliberately, with a diagnostic directory that has enough disk space.
--expose-gc belongs in the same bucket of "use carefully." It exposes global.gc(). That is useful for benchmarks, tests, and controlled diagnostics. In a server, calling it from request-path code usually turns one memory issue into a latency issue. V8's collector already has visibility into allocation pressure. Manual collection is a tool for experiments, not a service-level policy.
--zero-fill-buffers changes Buffer allocation behavior. Buffer allocation was covered in Chapter 2, including the unsafe allocation path. The flag tells Node to zero-fill newly allocated Buffer memory. That trades speed for data hygiene. Some organizations set it globally because stale memory exposure is a bigger concern than allocation throughput in their threat model.
Diagnostics Flags
Diagnostics flags are startup switches for observability and failure capture. They cost something. Sometimes the cost is tiny. Sometimes it is a file write during a crash, a profiler sampling thread, extra stack capture, or a larger error object.
Source maps are a common one.
node --enable-source-maps dist/server.jsWith that flag, stack traces can map generated JavaScript locations back to original source locations using Source Map V3 data. TypeScript projects and bundled services often need it. The mapping work happens when stack traces are prepared. Large source maps and many stack traces can add overhead, so production services should test the actual build output rather than assume the flag is free.
Warnings can be tuned from the CLI.
node --trace-warnings server.js
node --throw-deprecation server.js--trace-warnings prints stack traces for process warnings. --throw-deprecation turns deprecations into exceptions. --no-warnings silences warnings. --redirect-warnings=file writes them to a file. These flags change process-level reporting, and that means they influence warnings from your code, dependencies, and Node internals.
Diagnostic reports are structured JSON documents that capture runtime state.
node --report-on-fatalerror --report-directory=./reports server.jsA fatal error report can include native stack information, JavaScript stack information, resource usage, libuv handle state, environment data unless excluded, and component versions. Reports are useful when the process dies outside normal exception handling. They also contain sensitive configuration data by default. --report-exclude-env exists because environment variables often contain credentials.
CPU and heap profiling can start from flags.
node --cpu-prof --cpu-prof-dir=./profiles server.jsThe CPU profiler samples execution and writes a profile before exit. Heap profiling tracks allocation data. Treat these as profilers, with runtime overhead and output artifacts that need storage, collection, and retention rules. They belong in a planned diagnostic run or a controlled production incident procedure.
Tracing flags get even lower.
node --trace-events-enabled \
--trace-event-categories=node.async_hooks server.jsTrace events produce structured event data for selected categories. The category set defines what gets recorded. The output file pattern can include process id and rotation markers. Trace data can grow fast, especially when async hooks or V8 categories are enabled. Use short windows and targeted categories.
Inspector Flags
The inspector is configured before user code runs because the debugger may need to pause on the first line.
node --inspect=127.0.0.1:9229 server.jsThat starts the inspector agent and listens on the given host and port. The default host is 127.0.0.1, which binds to the loopback interface. Binding to 0.0.0.0 exposes the inspector to the network interfaces of the machine or container. That is a security decision, because the inspector can evaluate code in the process.
--inspect-brk enables the inspector and pauses before the first user statement.
node --inspect-brk app.jsUse it for local debugging. In automation it can leave the process waiting before application startup, which usually reads as a hung deploy or a stalled test job. --inspect-wait is even more explicit: Node waits for a debugger to attach before continuing.
Inspector flags are allowed in NODE_OPTIONS, so inherited environment can accidentally expose a debug port. Some production systems set NODE_OPTIONS=--inspect=0.0.0.0:9229 during emergency debugging and forget to remove it. Prefer short-lived overrides scoped to one process launch. Log process.execArgv and process.env.NODE_OPTIONS around startup when investigating exposed ports.
The runtime has permission-model interactions too. In v24, enabling the permission system restricts inspector access unless the appropriate allow flag is present. The permission model gets its own subchapter, so hold the detail for later. The practical point here is that runtime flags can compose. A debug flag, a permission flag, and a process-manager environment value can all affect the final process.
Module Loading Flags
The CLI can affect module loading before the first application import. The most common hooks are preloads.
node --require ./register.cjs app.js--require loads a CommonJS module before the entry file. It is repeatable. It also runs before --import preloads. It runs in the main thread before the application module, so it can install instrumentation, register transpilers, patch globals, or load config. The module system mechanics are covered in Chapter 6; the CLI part is the timing. Preload first. Main entry after.
ES module preloads use --import.
node --import ./register.mjs app.mjs--import preloads an ES module. It can run asynchronous module evaluation before the entry module starts. That makes it a better fit for ESM-only setup code, loader registration, and web-platform-style initialization. Keep it short. A slow preload makes the process look slow before the application has emitted a single log line.
Custom loaders use --experimental-loader. v24 also accepts --loader as an alias, but marks the loader flag path as discouraged and points new setup code toward --import with register().
node --experimental-loader ./loader.mjs app.mjsLoaders can intercept ESM resolution and loading. They participate in the module graph for user code. A loader can change how specifiers resolve, how source is fetched, or how source is transformed. That gives build tools and instrumentation hooks a place to integrate. It also means a loader bug can break every import in the process.
Package condition flags affect exports and imports resolution.
node --conditions=development app.jsThe exports field was covered in Chapter 6. Conditions are the labels Node considers when selecting an export target. Node always has its built-in condition set, and --conditions adds user conditions. Libraries sometimes expose development, production, node, or custom targets. A condition flag can make the same package specifier resolve to different files.
Symlink flags also affect resolution.
node --preserve-symlinks --preserve-symlinks-main app.jsThese flags tell Node to preserve symbolic link paths during module resolution. That can change cache identity, dependency lookup, and peer dependency behavior in linked workspaces. Use them when your package manager or monorepo layout expects symlink identity to be preserved. Otherwise, default realpath behavior tends to match deployed installs better.
TLS, Crypto, And Network Defaults
Some flags configure platform libraries under Node. TLS and crypto flags are in that category because OpenSSL setup happens during process initialization.
node --use-system-ca server.js--use-bundled-ca, --use-openssl-ca, and --use-system-ca choose certificate trust behavior. The default depends on how Node was built and the platform. The flag belongs at startup because TLS clients and servers created later will read from already configured certificate stores.
Extra CA certificates come through an environment variable rather than a CLI flag.
NODE_EXTRA_CA_CERTS=./company-ca.pem node server.jsNode reads that variable once during startup. Later mutation of process.env.NODE_EXTRA_CA_CERTS changes the JavaScript environment object only; the certificate store keeps the startup value. That "read once" behavior is common for variables that configure native subsystems.
TLS version flags set protocol bounds.
node --tls-min-v1.2 --tls-max-v1.3 server.jsThose defaults affect TLS contexts unless code overrides them. They are useful for organization-wide policy. They are also easy to misuse when an older dependency or upstream service still requires a different protocol setting. Treat them as runtime policy, then test the actual outbound and inbound connections your service uses.
DNS result ordering is another cross-cutting default.
node --dns-result-order=ipv4first server.jsThe flag changes the default ordering used by dns.lookup(). That can affect connection behavior for hosts with both IPv4 and IPv6 records. The networking chapter owns the mechanics; here the point is scope. A startup flag changes default behavior for every call site that relies on the process default.
Node v24 also has --use-env-proxy.
HTTP_PROXY=http://proxy.local:8080 \
node --use-env-proxy client.jsWith that flag, Node parses proxy environment variables and applies them to global HTTP and HTTPS clients where the runtime supports the setting. Proxy behavior is process policy. Centralizing it in runtime configuration can be cleaner than spreading proxy parsing through application modules, but it also means deployments need exact environment hygiene.
Experimental JSON Config
The JSON configuration file is experimental in v24. The flag names say that up front.
node --experimental-config-file=node.config.json app.jsThe config file gives Node a structured source for supported runtime options. Use it for Node runtime settings: flags that affect test runner behavior, watch behavior, module conditions, source maps, TypeScript stripping, permission settings, and other CLI-owned behavior. Database URLs and feature switches belong in application config.
v24's precedence rules make the file a baseline above dotenv-provided NODE_OPTIONS. Real NODE_OPTIONS can override it. The direct command line can override both. That keeps ownership visible during reviews.
A config file also makes hidden flags less hidden. A project that requires --conditions=development or --experimental-transform-types can record that in source control instead of relying on a README line or a package script.
The experimental status matters. Flag shape, schema support, and accepted namespaces can still change across Node releases. Pin your Node version when using it in a project template. Treat upgrades as runtime behavior changes, not just dependency bumps.
--experimental-default-config-file asks Node to look for its default config filename. That works well for a repository that wants convention over a long command. It also makes startup behavior depend on the current working directory and file presence, so process managers should launch from a predictable directory.
Env Files And Startup Timing
--env-file loads environment variables from a file during startup.
node --env-file=.env app.jsThe environment-file subchapter covers parsing details. For runtime configuration, the timing is the part to remember. Node reads the file before the main entry executes. Values from the file can populate process.env for application code. They can also supply NODE_OPTIONS through the env-file path in v24, which means a file can influence later option parsing at the dotenv tier.
--env-file-if-exists gives the optional version.
node --env-file-if-exists=.env.local app.jsThat form suits local overrides. Missing file, clean startup. Present file, environment values loaded. It reduces wrapper-script noise for projects that allow developer-specific env files.
Watch mode has a special interaction. Node's native startup code avoids applying the env file in the supervising watch process because the restarted child process needs to load it fresh. That keeps changes in the env file visible across restarts. It also means watch mode has a parent/child split in startup behavior, which can show up when you log process ids or process.execArgv.
Environment file values are still environment values. After they land in process.env, they behave like strings. The CLI connection is earlier: file parsing can happen before user code, and NODE_OPTIONS from that source can affect runtime flags below JSON config and direct environment settings.
Application Args
After Node consumes its own flags, the remaining arguments belong to the program.
node app.js --port 3000 --jsonprocess.argv contains the executable path, the entry path, then --port, 3000, and --json. Node passes those application tokens through as strings. Your code or a CLI library decides whether --port expects a number and whether --json is a boolean.
The split gets tricky when Node flags appear after the entry file.
node app.js --inspectHere --inspect is an application argument. The inspector stays closed. The entry file ended Node's option parsing. Put Node flags before the entry point.
The -- marker is clearer for CLIs that accept flag-shaped values.
node cli.js -- --inspect --max-old-space-size=64Your program receives both strings. Node treats them as data. CLI tools that run other commands should preserve this boundary carefully, because a wrapper can accidentally turn user data into runtime flags when it rebuilds a command string.
Child processes add one more layer. child_process.fork() receives an execArgv option. By default it inherits the parent process's process.execArgv. That is convenient for debugging and source maps. It can also copy memory caps, preloads, and experimental flags into workers you expected to start cleanly.
fork("./worker.js", [], {
execArgv: ["--enable-source-maps"],
});That explicit list replaces inherited Node flags for the forked child. Use it when the child process has different needs from the parent. A web server and a build worker often should not share the same inspector or heap flags.
Flags That Belong In Production
Production runtime flags should be boring. Memory caps. Source maps if your stack traces need them. Report-on-fatalerror if your incident process collects reports. TLS and CA policy if your organization requires it. Permission flags when the application is built for that model. Preloads for instrumentation when the owner understands the startup cost and failure behavior.
A good production command is readable.
node \
--max-old-space-size=2048 \
--enable-source-maps \
--report-on-fatalerror \
server.jsEvery flag has a reason. Every artifact has a directory. Every inherited value is visible in deployment configuration.
Some flags belong in one-off diagnostics.
node --trace-sync-io server.js
node --trace-deprecation server.js
node --cpu-prof server.jsThese change output volume or runtime overhead. They are excellent during a focused investigation. Leaving them on by accident creates noisy logs, large files, or latency changes that hide the original issue.
Other flags belong in local development.
node --watch --inspect-brk app.jsThat command is productive on a laptop. In a process manager, it creates a service waiting for a debugger and restarting on file changes. The environment decides whether it makes sense.
The risky category is hidden global defaults. NODE_OPTIONS in a shell startup file. A base container image that sets tracing. A CI image that injects preloads. A process manager template with an inspector port. Hidden defaults make runtime behavior feel random because the repository command looks clean while the process receives extra flags.
The fix is boring too. Keep Node flags near the launch definition. Prefer package scripts, service manifests, container command arrays, or checked-in config files over shell-global NODE_OPTIONS. When NODE_OPTIONS is the right tool, scope it to the exact process environment.
Reading Active Configuration
Inside a running process, the public inspection points are limited but useful. process.execArgv gives Node flags from the direct command line. process.argv gives the program arguments. process.env.NODE_OPTIONS gives the raw inherited string if it exists. process.config describes build-time configuration, which is a different thing. process.versions gives component versions.
console.log({
node: process.versions.node,
execArgv: process.execArgv,
nodeOptions: process.env.NODE_OPTIONS,
argv: process.argv.slice(2),
});That is enough for startup diagnostics. It tells you which Node version launched, which direct flags and inherited option string are visible from JavaScript, and which arguments reached the application.
There is also internal metadata behind node:internal/options, used by Node's own JavaScript code. Avoid importing it in application code. Internal modules can change without the compatibility guarantees public APIs receive. For production apps, log public state and control the launch environment.
Some configuration disappears after startup. V8 heap sizing becomes isolate state. OpenSSL setup becomes process library state. CA certificates from NODE_EXTRA_CA_CERTS are read once. The inspector agent may already be listening. Preload modules may already have mutated globals. Reading process.env later shows strings, not every native decision that has already happened.
So treat runtime configuration as an input artifact. Keep the command, environment, env files, and config file together in deployment records. If a process behaves differently under systemd, Docker, npm, and direct shell launch, diff the launch artifacts before debugging JavaScript.
Flags As Operational API
Runtime flags become part of your service API even though users never call them. They define the process envelope: memory ceiling, debug exposure, module graph inputs, trust store, diagnostics, and failure reporting. Changing a flag can change observable behavior as much as changing a line in server.js.
That means flag changes deserve review. A pull request that adds --conditions=development to a production script can route package exports to different files. A change from --use-bundled-ca to --use-system-ca can alter outbound TLS validation. Adding --require ./instrument.cjs can run code before the application logger, metrics client, or config module initializes. Removing --enable-source-maps can turn useful stack traces into generated bundle locations during the next incident.
The review should ask who owns the flag, where it is documented, how it is tested, and how it rolls back. Memory flags can be tested with load and heap profiles. TLS flags can be tested with real upstream endpoints. Preload flags can be tested by logging boot order and failure behavior. Diagnostic flags can be tested by forcing the artifact path in a staging environment.
Put version assumptions beside experimental flags.
node --experimental-transform-types src/main.tsThat command is tied to a Node line and an experimental implementation. A future Node upgrade can change accepted syntax, warnings, or transformation behavior. Record the target Node major and minor in the same place you record the command. CI should run the command under the same Node version that production uses.
Also separate runtime config from build config. A bundler flag affects the files produced before Node starts. A Node flag affects the process that runs those files. Mixing the two in one script can be fine for local development, but production launch should show the final runtime command clearly. Build once. Run with explicit runtime flags.
Containers make this easier when the image has one entrypoint and the deployment supplies only environment. They also make hidden defaults easier to miss when the base image sets NODE_OPTIONS. Inspect the final image environment. A base image is code for startup behavior.
When a flag becomes permanent, move it from an ad hoc incident command into the normal launch path. Incident commands are optimized for speed. Permanent commands need readability and ownership. A service that always needs source maps should state that in its service manifest, not in an on-call note.
One more practical detail: keep flags out of shared library code. Libraries can read runtime state, but they should avoid depending on a specific launch flag unless the application passes an explicit option. A module that silently expects --conditions=foo or a preload side effect makes tests and workers drift from the main service. Let the application boundary translate runtime flags into ordinary configuration. That keeps package code reusable across direct runs, test runner processes, and forked workers as well.
Flags are small. Their blast radius is process-wide.
Common Failure Modes
The first failure mode is flag placement.
node server.js --max-old-space-size=4096That passes --max-old-space-size=4096 to the server. Node's heap cap stays at its default. Put Node flags before the script name.
The second is inherited NODE_OPTIONS.
NODE_OPTIONS='--require ./test-hook.cjs' node server.jsThe preload runs before the server. If that variable came from a shell profile or CI environment, the repository command gives no hint. Check process.env.NODE_OPTIONS and the process environment.
The third is singleton override confusion.
NODE_OPTIONS='--dns-result-order=ipv4first' \
node --dns-result-order=verbatim app.jsThe command-line value wins. If you expected the environment to enforce policy, the launch command just overrode it. Put enforced policy in the layer that owns the command, or validate it at startup and fail fast when it differs.
The fourth is repeatable flag accumulation.
NODE_OPTIONS='--require ./a.cjs' \
node --require ./b.cjs app.jsBoth preloads run. If both patch the same API, order decides the result. Prefer a single preload that imports smaller setup modules in an explicit order.
The fifth is config drift across Node versions. v24 has flags that older LTS lines lack, and newer lines may change experimental behavior. A flag accepted by a developer machine can fail in production if the container uses a different major version. Pin the runtime. Run node --version in health diagnostics or startup logs. Keep the major version part of the deployment contract.
A Practical Startup Pattern
For service code, I prefer a small startup print in non-sensitive environments. Version. Entrypoint. execArgv. Raw NODE_OPTIONS. Selected application config keys after parsing. Redact secrets. Keep it behind a debug setting or one-time boot log.
console.log("node", process.versions.node);
console.log("execArgv", process.execArgv);
console.log("NODE_OPTIONS", process.env.NODE_OPTIONS);
console.log("argv", process.argv.slice(2));That tiny log has solved a lot of weird bugs. Wrong Node version. Missing source maps. Inspector inherited from a parent shell. Test preload running in a server. A memory flag placed after the script name. The log leaves native state opaque, but it narrows the problem to launch configuration fast.
The deeper rule is simple: decide which layer owns each setting. Runtime behavior goes to Node flags. Application behavior goes to application config. Secrets go to environment or secret storage. Developer convenience goes to local scripts. Incident diagnostics go to temporary overrides. Mixing those layers works for small projects and then becomes expensive when the same service runs under a process manager, a container, a test runner, and a developer shell.
Node gives you a lot of startup switches because the runtime has real work to configure before JavaScript begins. Use them with the same care you use for code changes. A flag can change memory layout, module resolution, TLS trust, diagnostics, debugger exposure, and entry-mode selection. That is production behavior, even when it fits in one command-line token.