If you're building a full-stack app with Vite and you have any server-side singletons — schedulers, database connections, telemetry adapters, bootstrap flags — you probably have a bug you haven't noticed yet.
The Symptom
Our dev server kept crashing with out-of-memory errors. Not immediately — it would run fine for a while, then slow down, then die. Restarting fixed it. For a while.
The Cause
We had a job scheduler that ran on an interval:
let scheduler: JobScheduler | null = null;
export function getScheduler() {
if (!scheduler) {
scheduler = new JobScheduler();
scheduler.start(); // setInterval inside
}
return scheduler;
}
This is the standard singleton pattern. It works perfectly in production. In development, it's a memory leak.
Every time Vite performs a hot module reload, the module re-executes. scheduler resets to null. getScheduler() creates a new one with a new setInterval. But the old interval is still running — setInterval lives on the global event loop, not in module scope. It doesn't get garbage collected.
Edit a file. Save. New scheduler. Old one still ticking. Save again. Three schedulers. Each one consuming memory, running jobs, making database calls. After a few dozen saves across a morning of development, the process runs out of memory and crashes.
The insidious part: you never see an error. Everything appears to work. You just get slower and slower until you don't.
The Same Bug, Three More Times
Once we found it in the scheduler, we found it everywhere:
Bootstrap flag. We had a let bootstrapped = false guard that ran role upserts on first request. After HMR, bootstrapped resets to false, and the bootstrap logic runs again. Not catastrophic, but unnecessary database writes on every save.
Telemetry adapter. A singleton telemetry collector. After a few reloads, you have multiple collectors sending duplicate metrics. Your dashboards look great though — traffic is up 300%!
Setup timer. A one-time initialization window guarded by let setupChecked = false. After HMR, the setup dialog could reappear in development, confusing everyone.
The Fix
globalThis survives module re-execution. It's the one scope that Vite's HMR doesn't reset:
declare global {
// eslint-disable-next-line no-var
var __app_scheduler: JobScheduler | undefined;
}
export function getScheduler() {
if (!globalThis.__app_scheduler) {
globalThis.__app_scheduler = new JobScheduler();
globalThis.__app_scheduler.start();
}
return globalThis.__app_scheduler;
}
The first HMR reload creates the scheduler. Every subsequent reload finds it already there and skips creation. One scheduler. One interval. No stacking.
We adopted a convention: __ double underscore prefix for all globalThis singletons. Makes them immediately recognizable and greppable:
globalThis.__bootstrapped // one-time init flag
globalThis.__telemetryInstance // singleton adapter
globalThis.__appStartTime // setup window timer
globalThis.__mongoose_conn // database connection
When NOT to Use It
Not everything needs globalThis. If resetting on HMR is harmless — or even desirable — leave it as a module-level let:
- Caches that lazy-load from the database. Resetting just means a cold cache after reload. Fine.
- Lazy model imports. Re-importing has no side effects.
- Derived state that rebuilds from source data.
The rule: if the thing has side effects that accumulate (intervals, connections, event listeners, database writes), use globalThis. If it's just data that can be rebuilt, don't.
The Other Half of Our OOM
The globalThis fix solved the scheduler stacking, but we were still hitting memory pressure. Turned out we had a second problem compounding it: Vite was watching 2,500+ generated files in our build output directory. Every HMR cycle had to re-scan all of them.
// vite.config.ts
server: {
watch: {
ignored: ['**/.radish/**', '**/node_modules/**']
}
}
Two lines. Combined with the globalThis fix, our dev server went from crashing every hour to running indefinitely.
The Takeaway
let at module scope is a lie in HMR environments. It looks like it persists across the lifetime of your server, but it resets on every file save. For most variables this is fine. For singletons with side effects, it's a silent, accumulating disaster.
globalThis is ugly. The declare global boilerplate is ugly. The __ prefix convention is ugly. But it works, and the alternative is phantom schedulers eating your RAM while you wonder why your laptop fan is screaming.