Skip to main content

Configuration Hot Reload

The ConfigWatcherService watches file sources for changes. On a change it triggers ConfigService.reload(), which re-loads all sources, optionally re-validates against the schema, and notifies subscribers registered via ConfigService.onChange().

The model is deliberately simple, and it pays to know its shape before you build on it:

  • Subscription is the onChange(listener) callback — there is no config:changed event on the application bus.
  • The watcher uses Node's fs.watch; there are no polling or debounce options.
  • A file change reloads and re-validates the whole config and fires one event — there is no per-leaf-key diffing.

The rest of this page walks through each of those in detail, with source references into packages/titan/src/modules/config/.

When hot reload is the right tool

It saves a restart cycle. Useful for:

  • Feature flags. Toggle a feature without redeploying.
  • Rate limit thresholds. Tighten or loosen during an incident.
  • Log levels. Bump to debug to investigate a live issue.
  • Cache TTLs. Adjust without invalidating.

It is the wrong tool for:

  • Database connection strings. A live connection pool can't be swapped without dropping in-flight calls.
  • Service identities. Renaming users@1.0.0 mid-flight breaks every connected client.
  • Schema changes. The framework expects the schema to be stable between boot and shutdown.

For "wrong tool" cases, restart the process.

Enabling

File watching is opt-in. Turn it on with watchForChanges:

ConfigModule.forRoot({
schema: AppConfigSchema,
sources: [...],
watchForChanges: true,
})

Omit it (or set it false) to disable watching. On onInit, ConfigService only attaches the watcher when both watchForChanges is truthy and a watcher service is present; the watcher's change handler is what calls reload() (the watcher wiring in ConfigService.initialize, and ConfigService.handleConfigChange). Only file sources are watched — ConfigWatcherService filters the source list to type === 'file' and ignores the rest (ConfigWatcherService.watch).

Subscribing to changes

Register a listener with ConfigService.onChange(). It adds the listener to an internal Set and returns an unsubscribe function that removes it (ConfigService.onChange) — call it in onDestroy to avoid leaks. There is no config:changed event on the application bus; the callback is the only subscription path.

@Service('cache@1.0.0')
class CacheService implements OnInit, OnDestroy {
private unsubscribe?: () => void;

constructor(private readonly config: ConfigService) {}

async onInit() {
this.applyTtl(this.config.get<number>('cache.ttlMs'));

this.unsubscribe = this.config.onChange((event) => {
// A file reload emits a single whole-config event
// (event.path === ''); re-read the key you care about.
this.applyTtl(this.config.get<number>('cache.ttlMs'));
});
}

async onDestroy() {
this.unsubscribe?.();
}

private applyTtl(ms: number) {
this.cache.setDefaultTtl(ms);
}
}

The listener receives an IConfigChangeEvent:

interface IConfigChangeEvent {
path: string; // dotted path; '' for a whole-config reload
oldValue: any;
newValue: any;
source: string; // e.g. 'reload' or 'runtime'
timestamp: Date; // a Date, not epoch ms
}

What gets emitted

A file change triggers a full reload(), which fires one event with path: '', source: 'reload', and oldValue / newValue holding the entire previous / new config object — it does not diff and emit per leaf key (in ConfigService.reload). A programmatic config.set(path, value) is the other emit path: it fires a targeted event for that path with source: 'runtime', carrying the old and new value at that path (ConfigService.set).

So inside an onChange handler, branch on event.source (or on event.path === '') and re-read the specific keys you care about from ConfigService rather than relying on a per-key payload from a file reload.

Validation on reload

reload() only re-validates when validateOnStartup is set and a schema is present (validateOnStartup in ConfigService). In that case a reload that fails validation is rejectedreload() restores the previous config and throws, so the running app keeps its last-known-good values (the validation branch of ConfigService.reload). The reload is triggered from the watcher's change handler (handleConfigChange), which awaits reload() inside a try/catch and logs the failure rather than propagating it (ConfigService.handleConfigChange).

This means a typo in a config file does not crash the running app. You see the error in the logs, fix the file, and the next change applies.

Note: if validateOnStartup is not enabled, a reload swaps in the new config unconditionally. Enable validateOnStartup if you want bad reloads rejected.

Atomicity

reload() re-loads and merges the whole config, runs validation, then swaps the result in atomically. There is no intermediate state where half the keys are updated and half are not. A single whole-config onChange event fires after the swap.

Watch mechanism

The watcher resolves each file source's path and opens a Node fs.watch on it, de-duplicating already-watched paths (ConfigWatcherService.watch). It reacts to fs.watch events whose eventType is 'change', calling back into ConfigService to reload. There are no polling-interval or debounce options — the change event triggers a reload directly, so a rapid burst of writes can drive several reloads. unwatch() closes every FSWatcher and clears the tracked set (ConfigWatcherService.unwatch); ConfigService calls it on both onStop and onDestroy. On filesystems where fs.watch is unreliable (some NFS mounts), prefer a restart-on-change deployment or a remote source instead.

Multi-pod considerations

Hot reload changes config only on the pod where the file was modified. For multi-pod deployments:

  • Use a config tool that pushes to all pods (e.g. K8s ConfigMap + pod restart, or a remote source that polls).
  • Or restart pods on config change — losing the hot-reload benefit but gaining cross-pod consistency.

The remote source type (experimental) is built for the push-to-all-pods case; it polls a central server.

Anti-patterns

  • Treating the onChange callback as transactional. If your handler fails partway, you have inconsistent state. Make handlers idempotent.
  • Restarting a pool on every reload event. A file reload fires a whole-config event even when the key you care about didn't change. Compare the value you read against the current one before acting.
  • Mutable singletons reading config in hot paths. Read once at onInit and update inside onChange. Reading on every call pays the lookup cost forever.
  • Forgetting to unsubscribe. onChange returns a disposer; call it in onDestroy.

→ Back to Configuration Overview.