The feature flag that didn't fire
Our documented escape hatch for a native dependency didn't work — the app crashed on deploy anyway. A short war story about how `import` runs before your code does, and why a feature flag can't gate a static import.
The short version
We ship on-device embeddings using a machine-learning runtime with a native
(.node) binary. On our serverless host that binary isn't available, so we
added an environment flag — DHAGA_EMBEDDINGS=off — and documented it as the
fix. It didn't work. A whole page crashed on deploy with libonnxruntime.so.1: cannot open shared object file, even with the flag set to off.
The cause is a one-liner most engineers have written a hundred times: a
top-level import. Static imports run when a module loads — before any of
your feature-flag code gets a chance to run. So the flag skipped calling the
library but never stopped it from loading. The fix is to import the heavy
dependency dynamically, inside the code path the flag already guards. This is
the whole story, because the lesson is worth more than the bug.
The rest of this post is the deep dive: the exact crash chain, why a barrel file made it worse, and the rule we now apply to every native dependency. Links point at the real code.
The feature: optional on-device embeddings
Dhaga can generate vector embeddings on-device (free, private) using a
transformer runtime. That runtime's backend depends on a native onnxruntime
binary. On some serverless targets that .so/.node file isn't present, so we
built an escape hatch: set DHAGA_EMBEDDINGS=off and the app should run fine
without embeddings. Our deploy docs recommended exactly that.
The embedder lives in
apps/web/src/lib/ai/embedder.ts.
The flag check —
embeddingsEnabled() —
returns false when DHAGA_EMBEDDINGS === "off", and embed() bails out early
when it's off. In theory, off means the native library is never touched.
The crash: off, and still dead
On the serverless deploy, with the flag off, the /app/people page crashed:
Error: libonnxruntime.so.1: cannot open shared object file:
No such file or directoryThe documented mitigation didn't mitigate. And the page that crashed —
/app/people — is a read-only page that has nothing to do with generating
embeddings. Two mysteries: why does an off flag still load the native library,
and why does a read page pull in the embedding runtime at all?
Why: import runs before your code does
Here's the one that's easy to know and easy to forget. A static, top-level import executes at module-load time — the instant anything imports that module — not when you call a function inside it. The original embedder had, at the top of the file:
import { pipeline } from "@huggingface/transformers";That package, when it loads, does its own static
import … from "onnxruntime-node", which tries to dlopen the native
libonnxruntime.so.1. All of that happens as a side effect of the module being
imported — before embeddingsEnabled() is ever evaluated. So the flag was
doing exactly what it said: it skipped calling pipeline(). It never had a
chance to stop the library from loading, because loading already happened when
the import line ran.
A feature flag can gate a function call. It cannot gate a static import — the import already ran to put the function in scope.
Why a read-only page: the barrel file
The second mystery is about how the crash reached /app/people, a pure-read
page. The answer is a barrel file — an index.ts that re-exports its
siblings —
apps/web/src/lib/repo/contacts/index.ts.
It re-exports both queries.ts (read-only, all the people page needs) and
mutations.ts. mutations.ts imports a delete helper that imports
repo/embeddings.ts, which imports embedder.ts, which — at the top — loaded
onnxruntime-node.
So importing anything from the contacts barrel loaded the entire delete → embeddings → native-runtime chain, even for a page that only wanted a read query. Barrel files are convenient, but they make every consumer pay for the heaviest sibling in the barrel.
The fix: make the import dynamic and gated
The fix is small and precise: don't import the heavy dependency until you're actually inside the flag-guarded path.
- Change the top-level value import to an
import type— which is erased entirely at compile time, so it loads nothing at runtime. - Move the real import to a dynamic
import("@huggingface/transformers")insidegetExtractor(), the function that only runs when embeddings are enabled — and wrap it so a missing native binary degrades gracefully instead of crashing.
Now, with DHAGA_EMBEDDINGS=off, getExtractor() is never called, the dynamic
import never runs, and onnxruntime-node is never loaded. The flag finally does
what the docs always claimed it did. Read pages don't touch it, the deploy is
green, and self-hosters without the native binary get a clean degrade rather than
a crash.
The rule we apply now
This generalized into a standing rule, worth stating plainly:
Any dependency with a native (
.so/.node) binary — or anything listed inserverExternalPackages— must be imported dynamically, inside the code path that a feature flag already guards. Never as a static top-level import.
And a corollary about barrel files:
A heavy or native import in one sibling of a barrel (
index.ts) loads for every consumer of that barrel, including the ones that only needed a lightweight sibling. When a barrel mixes read and write modules, a read-only caller can still drag in a write-only native dependency.
The takeaways
- A feature flag can't gate a static import. Imports have side effects at load time, before your first line of logic. If the thing you're gating loads at import, the flag is decorative.
import typeis free; value imports are not. When you only need a type, say so — it's erased and costs nothing at runtime.- Gate heavy/native deps behind a dynamic
import()inside the already- guarded path, and handle the "binary missing" case gracefully. - Audit your barrel files. Re-exporting mutations and queries from one
index.tsmeans every reader pays for the writers' dependencies. Split them, or import the specific module directly. - Test the escape hatch on the target. "Set the flag to off" is only a real mitigation if you've verified it on the environment that lacks the binary — not just locally, where the binary happens to exist.
Discussion
Isolating tenants on serverless Postgres
Row-level security is easy to turn on and easy to get catastrophically wrong. How a connection pooler almost leaked one customer's data into another's in Dhaga Cloud, and the boot guard that makes it impossible.
A schema that heals itself on cold start
No migration tool, no migration files, and a 15-second sign-in. How a single content hash turned a full schema replay on every serverless cold start into a no-op — while keeping the zero-config, self-healing setup that makes Dhaga trivial to self-host.