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 noconfig:changedevent 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
debugto 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.0mid-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 rejected — reload() 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
validateOnStartupis not enabled, a reload swaps in the new config unconditionally. EnablevalidateOnStartupif 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
onChangecallback 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
onInitand update insideonChange. Reading on every call pays the lookup cost forever.Forgetting to unsubscribe.
onChangereturns a disposer; call it inonDestroy.
→ Back to Configuration Overview.