FAQ + Troubleshooting
The questions and errors that come up most often. Per-module pages each have an "Anti-patterns" section — this page collects the cross-cutting ones.
FAQ
Why decorators?
Decorators describe intent declaratively. The container reads the metadata and wires the rest. Alternatives (factory functions, builder APIs, configuration files) move the same information to a less discoverable place. Decorators put the contract next to the code it controls.
Trade-off: decorator-based DI requires
experimentalDecorators + emitDecoratorMetadata in
tsconfig.json. Everyone shipping production TypeScript has
this enabled anyway; new projects can use TC39 stage-3
decorators (also supported).
Why a custom DI container (Nexus)?
InversifyJS, TypeDI, tsyringe — each has its own ergonomics constraint. Nexus needed:
- Class tokens and symbol tokens and factory tokens.
- Scopes (singleton, transient, request, scoped).
- Contextual injection — one token, different providers per request context (multi-tenant, multi-environment).
- Cycle detection at startup.
- Lifecycle hooks tied to dependency order.
No existing container does all five. Nexus does.
Why MessagePack over JSON for the wire?
RPC must round-trip types exactly. JSON loses Date, Map, Set, BigInt, Error, Buffer; MessagePack with the package's extensions preserves all of them — and the wire is ~25% smaller. For service-to-service calls this is non-negotiable.
For human-readable APIs (debugger, curl), JSON over HTTP still works via a separate REST gateway.
Why one daemon, not per-app sidecars?
Sidecar-per-app multiplies operator overhead by N. Omnitron's single daemon:
- Owns one Unix socket; CLI calls cost nothing.
- Aggregates metrics / logs across all apps in one ring buffer.
- Survives app crashes — the daemon stays up, apps restart.
- Hosts the web console + MCP server at the same address.
For multi-tenant strict isolation, run one daemon per
~/.omnitron/ home directory.
Why monorepo?
The stack's value comes from cross-layer typing. A monorepo
with TypeScript references lets the frontend's useService<T>
import the backend's interface T directly — no codegen, no
publish step.
Polyrepo works (publish the shared interfaces as a package), but costs an extra round-trip on every contract change.
Is Titan production-ready?
Yes. The stack runs production workloads at meaningful scale. The modules ship with anti-patterns docs because production shaped them.
If "production-ready" means "wide adoption + many StackOverflow answers" — not yet at the NestJS level. The reference is the docs you're reading.
Why isn't there X module?
Because the work-set was deliberate. We shipped the modules
that show up in every backend (cache, auth, db, queue,
scheduler, …). For everything else, the authoring guide
describes how to publish a @your-scope/titan-* package that
plugs into the same DI grammar.
Domain-specific modules (e-commerce, ML, telecom, …) belong outside the core to keep the ecosystem focused.
Can I use Bun or Deno?
- Apps: yes, where the runtime matches your dependencies.
The utility packages (
common,cuid,eventemitter,msgpack) are explicitly cross-runtime tested. - Omnitron daemon: Node only — uses Node's
child_processin a way Bun and Deno don't fully match. - Tests: see Cross-runtime testing.
How does pricing / licensing work?
MIT across every package. No commercial fork, no enterprise tier, no support contracts. Use at any scale.
When should I NOT use this stack?
- Static sites / marketing pages — overkill. Use Astro / Next.
- Single-script automation — overkill. Use plain TypeScript.
- Languages other than TS — the value is end-to-end TS. If the team writes Go on the backend, this gives nothing over a protobuf pipeline.
- Edge-only workloads — Workers / Pages don't run the Node process model Titan assumes.
- You hate decorators — the DI grammar is decorator-first. Alternatives (Hono, Fastify, raw Node) work fine.
Troubleshooting
"Cannot resolve token X"
The container couldn't find a provider for token X. Causes:
| Cause | Fix |
|---|---|
| Module not imported | Add it to imports: [...] of the parent module |
| Provider missing | Add {provide: TOKEN, useClass: ...} to providers: [...] |
| Circular dependency | Restructure or use @Optional() + lazy resolution |
Two @omnitron-dev/titan versions installed | pnpm why @omnitron-dev/titan — should report one resolved version |
Wrong module reference (e.g., importing app Foo when service is in Bar) | Use app.resolve(TOKEN) to introspect, then trace declarations |
"Container class identity mismatch"
You see this when launching an app under Omnitron's module-worker mode:
The 'Container' class imported by the app's Titan modules is
not the same physical class as the daemon's 'Container'.
Cause: the app pinned a different @omnitron-dev/titan version
than the daemon, or the workspace has parallel
node_modules resolving differently.
Fix: pnpm dedupe, then pnpm why @omnitron-dev/titan — must
return one resolved version. If not, align peer-dependency
ranges across packages.
App boots, but first request hangs
Likely a heavy service hasn't initialised yet. Eager-load in
afterCreate:
hooks: {
afterCreate: async (app) => {
await app.container.resolveAsync(HEAVY_SERVICE_TOKEN);
// ... auth wiring ...
},
}
See Best practices / Eager-loading heavy services.
Watcher fires constantly in dev
The file watcher is detecting writes to dist/ or node_modules.
Fix: exclude them in watch.ignore:
watch: {
directory: './apps/api',
ignore: ['**/dist/**', '**/node_modules/**', '**/.turbo/**'],
}
Default ignores cover most cases but not project-specific paths
(.turbo, coverage, .next).
"Daemon already running"
The PID file at ~/.omnitron/daemon.pid exists and points to a
live PID. Either:
- Daemon is up:
omnitron statusto confirm; use it. - Stale lock: previous daemon crashed without cleanup.
omnitron killremoves the lock; thenomnitron up.
Heartbeat / discovery flapping
Nodes briefly disappear from omnitron node check.
Causes:
heartbeatIntervaltoo short (default 5 s is usually fine).- Network briefly slow (TLS handshake to managed Redis).
- Redis cluster failover.
Fix: bump heartbeatTTL to 5 × heartbeatInterval (default is
3×). See titan-discovery / TTL tuning.
Build fails with "broken anchor"
Docusaurus markdown links use kebab-cased anchor IDs derived
from headings. If you link to #someAnchor but the heading is
## Some long heading, the anchor is #some-long-heading.
Fix: match the auto-generated slug. Run the build; the warning shows the resolved path.
React component re-renders too often
Most common cause: useQuery running every render because args
change identity.
// BAD — new array every render → cache miss every time
const { data } = users.getUser.useQuery([id, { include: ['profile'] }]);
// GOOD — memoise the args
const args = useMemo(() => [id, { include: ['profile'] }] as const, [id]);
const { data } = users.getUser.useQuery(args);
Or extract the options:
const opts = useMemo(() => ({ include: ['profile'] }), []);
const { data } = users.getUser.useQuery([id, opts]);
TypeScript "Type X is not assignable to type Y" after server change
Symptom of the win. The contract is the type — when the server signature changes, every caller must update.
Fix: update the caller. Don't as any to silence — that's
exactly the bug end-to-end types are designed to catch.
"Maximum call stack exceeded" in middleware
Recursive middleware — usually a middleware calling next()
that re-enters the same middleware (forgotten guard, infinite
retry).
Fix: cap retries, ensure next() is called exactly once per
invocation.
Webapp shows ChunkLoadError after deploy
Old HTML cached in user's browser tries to fetch deleted JS chunks.
Fix: catch ChunkLoadError in the app shell (e.g. a global
error/unhandledrejection listener or a React error boundary)
and reload the page once per session so the browser fetches the
new HTML and chunk manifest.
Redis "ECONNREFUSED" on dev boot
The infrastructure container isn't up. Either:
omnitron up(auto-provisions infra).omnitron infra up(just infra).- Check
omnitron infra status(aliasps).
Database migration locked
Symptom: MigrationLockError. Cause: a previous deploy left the
advisory lock held — usually because the migration runner
crashed.
Fix:
SELECT pg_advisory_unlock_all();
Then re-run the migration. Investigate why it crashed before running again in prod.
"Health probe times out"
The health endpoint takes longer than health.timeout
(default 5 s).
Causes:
- Probe does a real DB query — should use the cached health summary.
- One indicator hangs — run
omnitron health-check <app>for the detailed per-probe report and find the slow indicator.
Fix: pre-cache the health result; set per-indicator timeouts.
"JWT expired" but I just signed in
Clock skew between server and client > clockTolerance
(default 5 s).
Fix:
- Sync NTP on both.
- For pathological cases, bump
clockToleranceto 30 s. Wider windows enable replay-after-expiry; don't go above 60 s in prod.
CLI command returns nothing
Use --json to see the raw output. Most commands suppress
output if there's nothing to display.
omnitron --json status
Pull-request fails CI on unrelated test
Check for shared state across tests. Common culprits:
vi.useFakeTimers()not restored — leaks into next test.- Singleton mock state —
vi.clearAllMocks()inbeforeEach. - DB row left over from a non-rollback test.
- File-system writes outside
tmp/.
Run failing test in isolation: vitest run path/to/file.test.ts -t 'test name'.
Where to look next
If the question above didn't help:
| Symptom | Page |
|---|---|
| Module-specific issue | The module's page → "Anti-patterns" section |
| RPC / wire issue | Netron transports + errors |
| Auth issue | Auth & RBAC |
| Daemon won't start | Daemon |
| Test issue | Testing |
| Slow / high CPU | Observability |
| Infrastructure (Postgres/Redis) | Infrastructure |
| Component renders wrong | Prism components |
| Want to contribute a fix | Contributing |
See also
- Comparison — context for "why this stack"
- Best practices — patterns that prevent most of the issues above
- Architecture — how the layers compose