Configure dev and production builds¶
You're about to ship. Two questions matter: what's actually in your production bundle, and which knobs do you need to touch? Take the second one first: almost none. The defaults are already correct. This page is the pre-ship pass. One piece at a time: the single flag that makes a build "production", then gating your own dev code so it disappears alongside the framework's, then the JVM/SSR variant, then the dev knobs, and finally the guardrails you can't turn off.
Coming from React?
You know NODE_ENV=production — the build where the bundler strips out dev warnings. re-frame2's flag is the same idea with a bigger reach. ClojureScript ships its production builds through Google's Closure compiler in :advanced mode — an aggressive optimiser that, among other things, deletes code it can prove will never run (this pass is called dead-code elimination, or DCE). ClojureScript also has a standard goog.DEBUG flag, and when you set it off, the Closure compiler doesn't just skip re-frame2's dev surface at runtime — it elides it from the bundle entirely: the dev-time schema diagnostics, the trace stream, epoch history, all gone. Zero cost, rather than a cost you've cleverly avoided. What doesn't go is anything the framework relies on to keep a promise of its own — and a few of those read a schema you wrote, so "I declared it" is not the test. The three piles below sort that out.
1. Production is one flag¶
Here is the whole production story — one line in your release build:
;; shadow-cljs.edn — the release build
{:builds
{:app {:target :browser
:release {:compiler-options {:closure-defines {goog.DEBUG false}}}}}}
That's it. Most production CLJS builds already set goog.DEBUG false, so re-frame2 reuses the canonical flag rather than inventing its own — odds are you have this line already.
Under that :advanced build with goog.DEBUG=false, the framework's surfaces — the distinct things you can attach to or read from, like the trace stream or schema validation — sort into three piles. Know which is which before you ship.
Elided — gone from the bundle, zero cost:
- The schema checks you declared over your own code — the
:schemaon an event, a subscription's return, an fx's args, an ambient cofx, a flow's output, and everyreg-app-schemapath onapp-db. Those assert something about code you wrote, and a release build takes you at your word. Schemas stay registered so tooling can still introspect them; they are simply never checked at these checkpoints. What survives is what the framework relies on to keep a promise of its own — two lists down, and note that it can be the very same:schema, read at a different checkpoint::boundary? trueon the registration keeps the handler's own declaration in force. (Validate with schemas) - The trace stream — the
:tracelistener stream and the per-frame trace rings; nothing emits, no listener ever fires. - Epoch history — the per-frame time-travel ring; nothing records, so there is nothing to rewind.
- Dev tooling attachment points — Xray and the pair server consume the trace surface; their artefacts must not be on a release build's classpath.
Survives — always-on by design:
- The event-emit and error-emit substrates — one tight record per processed event, and one per error record. This is the production observability surface, and it has one door: a frame
:observabilitysink. The frame declares:handled-events/:errorsentries with an egress profile, you register the sink fn withrf/register-observability-sink!, and each record arrives already projected under that frame's classification. Wire your APM and error monitor there. For a seat across every frame — and for records whose frame does not resolve — declare the same entries once with(rf/configure! {:observability …})rather than per frame. (Report errors in production) - Every guardrail in §5.
- A handler registered
:boundary? true— a boolean key in the registration metadata map. Set it and that handler's own:schemais checked on untrusted ingress (an HTTP reply, apostMessagepayload) regardless of the flag, at the router's resolve step, before any interceptor runs. In dev it changes nothing (the handler's:schemais already checked); in production it keeps that same check.:interceptor-overridescannot remove it, and registering it without a:schemakey is rejected on the spot with:rf.error/at-boundary-missing-schema. Set it on exactly those handlers; everything else stays zero-cost. The report survives with the check: a production rejection lands one:rf.error/schema-validation-failurerecord (:source :boundary) on the error-emit stream two bullets up, and settles that dispatch:outcome :rejectedon the event-emit stream beside it. The rich trace — the offending value, Malli's explanation — is the dev-only half; the production record is structural, carrying identifiers and no payload, because a boundary payload is untrusted by definition. (Validate with schemas) - The framework's other load-bearing checks — a recordable cofx's
:schema, which throws rather than record a value a later replay would reconstruct corrupt state from; a declared route's shape, checked whenever the schemas artefact is present and the route declares one; a managed-HTTP:decodeschema, which is an argument to the framework's parse of the response rather than a diagnostic over it; and the reserved:rf.server/*effects' own arguments. Same reason each time: a promise kept only in dev is not a promise. Note that most of these read a schema you declared — the recordable cofx's comes off your ownreg-cofx, exactly as a handler's:schemacomes off your ownreg-event, and only one of the two survives. What settles it is what the check is for, not who wrote it. (C-000.35 settles the rule.)
Opt-in: the Performance API channel rides its own independent flag — {:closure-defines {re-frame.performance/enabled? true}} — for event/sub/fx/render timing in production via PerformanceObserver. It's off by default in every build, so you turn it on only when you specifically want production timing. (Find and fix a slow view)
From re-frame v1
There is no separate tracing dependency and no 10x preload-and-closure-define dance: dev builds trace by default with zero config, and production elision rides the goog.DEBUG=false you were already setting. The single flag does the whole job.
Going deeper
Why is this elision and not a runtime if? The dev surfaces are written as (when ^boolean re-frame.interop/debug-enabled? …), and debug-enabled? is a goog-define — a constant the Closure compiler can fold. In :advanced mode with goog.DEBUG=false the predicate folds to the literal false, every guarded body becomes provably unreachable, and DCE removes it along with everything only it referenced. The "three piles" above aren't a policy the runtime enforces; they're a consequence of which code is reachable once one constant is pinned. That's why the cost isn't "small" — it's structurally absent, and the elision probe (npm run test:elision) asserts the strings simply aren't in the bundle.
2. Gate your own dev-only code¶
The framework elides its own dev surface; yours needs the same gate so it disappears alongside it. Any trace listener or debug hook you wrote should sit behind the framework's own predicate, placed as the outermost form:
(when ^boolean re-frame.interop/debug-enabled? ;; alias of goog.DEBUG
(rf/register-listener! :trace :my-app/console-tap
(fn [trace-event]
(js/console.log (:operation trace-event) trace-event))))
In production, debug-enabled? is the constant false, so the when body is dead code and the whole registration disappears with everything else.
One rookie mistake quietly defeats this gate: (when (and something debug-enabled?) ...) does not constant-fold. Closure can't rule out something, so it can't prove the branch is dead, and the code ships in your bundle. Keep debug-enabled? as the outermost test, on its own. The same applies to every dev-only call you write — a register-epoch-listener!, a trace-buffer read, a (rf/configure! {:trace-buffer …}) — each belongs under its own when ^boolean re-frame.interop/debug-enabled? guard.
3. Shipping a JVM/SSR tier? One system property¶
On the JVM there's no Closure compiler, so the same gate becomes a runtime flag instead of a compile-time one. It defaults to on for dev parity. A production SSR or webhook process facing untrusted input should flip it off, so the trace rings and epoch history don't retain user input:
Two spellings; pick whichever fits your deployment:
- Java system property
re-frame.debug— the-Dre-frame.debug=falseabove, on the JVM command line. - Environment variable
RE_FRAME_DEBUG— set in the process environment, which is often the cleaner fit for a containerised deploy (RE_FRAME_DEBUG=falsein the Dockerfile / orchestrator config, no command-line surgery).
Either accepts the conventional false-y vocabulary — false, 0, no, off, or the empty string, case-insensitively — and anything else (including unset) leaves the flag at its default of true. So a typo like RE_FRAME_DEBUG=disabled does not turn the gate off; it reads as an unrecognised value and dev mode stays on. If you set both at once, the system property wins over the env var.
The flag is read once, at namespace load, so set it before re-frame.interop loads — i.e. as a real process-level setting, not something you System/setProperty after the app has booted. With it off, every JVM-side dev surface drops to the same no-op floor that Closure DCE gives an :advanced + goog.DEBUG=false browser build: no trace rings, no epoch history retaining user input. The always-on event/error streams and the SSR error projector — the registered projector that turns a server-render failure into a safe, public-facing error page — keep firing; they exist precisely for this posture.
Going deeper
The browser path elides code; the JVM path can't (no Closure pass), so it reads re-frame.debug once into a plain def at namespace load and branches on that constant for the process lifetime. Reading once is deliberate: a per-call check would be a hot-path tax, and a value that can change mid-run would make "is tracing on?" ambiguous. The contract is the same on both substrates — gated off ⇒ no retention of user input — only the mechanism differs: DCE on CLJS, a load-time constant on the JVM.
4. The dev knobs: three buckets, one rule¶
Now the dev-side configuration. It lives in exactly three places, sorted by how long the configured thing lives, with one rule on top: one option, one bucket. Nothing is settable in two places, so there's never a question of where a setting "really" comes from.
One term in the table below: a frame is one isolated, running instance of your app — its own app-db, event queue, and subscription cache. (Frames isolate state, not registrations; see Frames.)
| Lifetime | Surface | What lives there |
|---|---|---|
| Process-wide, the value is plain data | (rf/configure! {key opts}) |
:epoch-history, :trace-buffer, :elision, :observability |
| Slot-level, the value is a swappable implementation | set-…! / install-…! |
schema validator/explainer, substrate adapter |
| One frame | frame config (make-frame) / dispatch opts |
:drain-depth, :observability, :fx-overrides |
The configure! bucket: process-wide data¶
configure! takes a single nested map; its vocabulary is just four top-level keys, fixed-and-additive, shown here at their defaults:
(rf/configure!
{:epoch-history {:depth 50} ;; how far time-travel rewinds
:trace-buffer {:events-retained 50} ;; events held for dev tools
:elision {:rf.egress/threshold-bytes 16384} ;; "too big for the wire"
:observability nil}) ;; production sinks; none by default
A missing top-level key leaves that subsystem untouched, so you can pass just the one knob you want — (rf/configure! {:trace-buffer {:events-retained 200}}) — or compose all four in one value. An unknown top-level key applies nothing, which is what lets a wrapper hand configure! a composed config without first filtering it.
Applying nothing is not the same as saying nothing, though. The four keys above are the whole closed vocabulary and they are bare, so a bare key the runtime doesn't recognise is almost always a typo of a real one — {:epoch-histroy {:depth 100}} silently tuning nothing is exactly the bug worth catching. In dev builds an unknown bare (or rf-namespaced) key emits :rf.warning/unknown-configure-key, naming what you typed and what the runtime reads. Your call still returns nil and still applies nothing — the warning is a signal, not a refusal — and it is compiled out of production entirely. A key under your own namespace (:myapp/thing) stays silent, so a wrapper composing its own config keys alongside re-frame2's is unaffected.
One thing fails loud, though: the argument must be a map. (rf/configure! [:trace-buffer …]) — a vector, say, because you mistyped — doesn't quietly do nothing; it throws. The applies-nothing behaviour is reserved for unknown keys inside a well-formed map; a malformed argument is a programming error and surfaces as one.
The four keys, in detail:
:epoch-history— depth of the per-frame epoch ring (a fixed-size circular buffer of recent app-db states) that powers Xray's time travel;:depth 0disables it. This one is dev-only: in production the ring elides whatever you set. It carries one more opt for the security-conscious deployment::trace-events-keep(a non-negative integer) caps how many raw trace events each epoch record retains. There is no scrub hook — an epoch leaving the process is projected byrf/project-egress(the one record-level egress door; it recognises an epoch record by its stamped:kind), and a forwarder that needs more than the framework's built-in data classification covers composes its own scrub over the projected value,(-> record rf/project-egress my-scrub). That never touches the in-process ring, so it never affectsrestore-epoch!fidelity. (Keep secrets and large things out of traces):trace-buffer— how many events (one dispatch each — one slot per event, regardless of how many trace events its run emitted) the dev trace ring retains; bump it for a bug spanning more user actions than the default 50.:events-retained 0disables retention while the surface stays live (listeners still fire; nothing is kept). Dev-only, same as:epoch-history.:elision— the size threshold above which the walker flags an undeclared large value with the:rf.warning/large-value-unschema'dadvisory on wire-bound surfaces. That advisory is a nudge, not a cap: the oversized value is still forwarded raw. Only values you declared large (or marked via a schema:large?) are replaced by the:rf.size/large-elidedmarker;:rf.egress/threshold-bytes 0turns off the runtime size auto-detection warning entirely. The:largeelision this key sits alongside is not dev-only — it shapes the always-on listener records your production monitors receive, so it matters in a release build too. (Keep secrets and large things out of traces):observability— the process default for production observation sinks, in exactly the grammar a frame's:observabilitytakes (below):{:handled-events [<entry>…] :errors [<entry>…]}. Declare your Sentry / Datadog policy once here instead of restating it on everymake-frame. The odd one out in this table in three ways, all deliberate. It is the only key that is not dev-only — it is the production wiring. It is the only one a barenilclears:(rf/configure! {:observability nil})removes the default, because a deployment must be able to take a policy out without knowing what put it in. And it is the only one validated at the call: a malformed policy throws:rf.error/bad-frame-classificationthere and then rather than installing quietly and surfacing as a record that never arrived. (Report errors in production)
Looking for :sub-cache?
It's gone. The old :sub-cache {:grace-period-ms N} knob — a deferred-disposal timer for subscriptions — no longer exists: a subscription is now disposed synchronously the instant its last reader lets go, so there's no grace window to tune. If you have it in an old config, drop it (it'll no-op as an unknown key, but it's dead weight).
The set-…! bucket: swappable implementations¶
You touch the set-…! bucket only to replace an implementation — a non-Malli validator or explainer via schemas/set-schema-fns! (on re-frame.schemas, not the rf/ front porch), a substrate via rf/init! — and on the happy path the boot wiring sets all of these for you, so most apps never call them directly.
The per-frame bucket: frame-lifetime overrides¶
The per-frame bucket rides the frame config (two of its keys — :fx-overrides and :interceptor-overrides — can also arrive per-dispatch on the dispatch opts argument, where the per-call value wins on conflict; the rest are frame-config-only). Its keys are the frame-lifetime ones — :drain-depth, :fx-overrides, :interceptor-overrides, :interceptors, :initial-events, :on-destroy, and the production-relevant :observability:
;; A frame that ships its own error sink — survives goog.DEBUG=false,
;; because production observability is a frame-owned policy, not a dev knob.
(rf/make-frame
{:id :my-app/main
:initial-events [[:my-app/boot]]
:drain-depth 100
:observability {:errors [{:sink :my-app.sinks/sentry}]}})
An :observability entry names a user- or library-owned :sink keyword (you register the sink; the framework routes pre-redacted records to it), with an optional :rf.egress/profile. The entry is a closed map — those two keys and no others; anything else fails loud at make-frame, and vendor configuration belongs in the sink fn you register, which closes over it. The two collections it accepts are :handled-events and :errors — the production read of the event-emit and error-emit streams, declared once on the frame rather than wired imperatively. (Report errors in production)
Most apps do not need this key at all: declare the policy once with (rf/configure! {:observability …}) above and every frame inherits it. Reach for the frame key when this frame differs. The two compose per stream, not per map — a frame declaring only :errors still inherits the default's :handled-events, so naming one stream never silently switches the other off. Declaring a stream as the empty vector ({:errors []}) is how a frame opts out: it names the stream and names no sink, which is a different statement from leaving the key off. Exactly one of the two is consulted per record, so a sink listed in both fires once rather than twice. What a frame inherits is the sink list — records are still projected under that frame's own classification, so an admin frame that classifies more redacts more even while sharing the default's Sentry entry.
Its safety-relevant knob is :drain-depth, which comes up next in the guardrails.
Why three buckets, not one config map?
The split sorts settings by the lifetime and shape of the thing being set: process-wide data (a number, a map) goes through configure!; a swappable implementation (a function, an adapter) goes through set-…!; a per-frame override rides that frame's metadata. Each shape has exactly one home, so reading config is never a scavenger hunt and merging two configs never produces a conflict — a property worth more than the convenience of a single grab-bag map.
Coming from React?
No .env files, no process.env reads scattered through the app, no a-context-provider-here-a-prop-there config drift. The closest analogy is a single typed config object — except it's split by lifetime: process-global data, swappable services, and per-instance overrides each have their own setter, so two pieces of config can never disagree about who owns a key.
Tune narrowly, usually for one debug session. If the knob you want isn't here, it doesn't exist — new knobs arrive by design change, not by accumulating flags. The full key catalogue is configure! in the API reference.
5. The guardrails you can't turn off¶
These run in every build, dev and production alike. Each one fails loud — rejecting with a structured :rf.error/* instead of silently stripping and warning — so a failure surfaces like any other bug rather than slipping past you.
- Drain depth (default 100, per-frame
:drain-depth) — a runaway dispatch drain halts at the ceiling with:rf.error/drain-depth-exceededinstead of freezing the tab. Already-settled events in the drain stay committed (each commit is atomic on its own); the offending event that tipped over the limit is the one that doesn't land. A drain near the ceiling is a bug to fix, not a number to raise — the error's recovery is:no-recoveryprecisely because hitting it always means runaway recursion. - HTTP keyword cap (
:rf.http/max-decoded-keys, default 10000) — a hostile JSON reply can't intern unbounded keywords and slowly kill a long-running process; the decode fails onto your:on-failurepath. - Slow-loris timeout (
:timeout-ms, default 30000) — every managed HTTP request gets a wall-clock per-attempt timeout; opting out is deliberately loud (:timeout-ms nil) so a reviewer sees it. - CRLF fail-fast — server-side
:rf.server/*response fx refuse to put a\ror\non the wire: a header:valuecontaining one throws with:rf.error/header-invalid-value, a redirect location containing one throws with:rf.error/redirect-invalid-location, and cookies go through structured maps that can't be string-spliced. Header injection is closed at the fx site, not normalised away. - Open-redirect guard —
:rf.server/redirectis caller-trusted (you composed the location), so it only gets the CRLF check above. For a location built from untrusted input — the classic?next=…query param — reach for:rf.server/safe-redirectinstead: it parses the URL, rejectsjavascript:/data:/vbscript:schemes (:rf.error/safe-redirect-scheme-rejected), rejects an unparseable URL (:rf.error/safe-redirect-invalid-url), and can gate to relative-only or an:allowhost allowlist (:rf.error/safe-redirect-host-disallowed). Dispatching attacker-controlled input straight into:rf.server/redirectis the open-redirect bug this variant exists to close. - Editor-URI scheme rejection — click-to-source links refuse
javascript:/data:/vbscript:schemes (everything else —vscode:,idea:,cursor:, future editor schemes — passes), so a custom editor template can't run script in your dev tab. - The
:rf/*reserved namespace — registering anything under an:rf-prefixed id is refused territory; one prefix answers "is this framework-owned?".
Why always-on, not dev-only?
A guardrail that only runs in development is a guardrail you've disabled for the exact users who can attack you. Each of these defends a production threat — a recursive dispatch DoS, a keyword-interning DoS, header injection — so each survives goog.DEBUG=false by design.
The elision mechanism itself — what disappears from a production build, and the two always-on streams that survive — is Observability's territory.
The pre-ship checklist¶
- Release build sets
{:closure-defines {goog.DEBUG false}}(most templates already do). - Your own dev-only registrations sit behind
^boolean re-frame.interop/debug-enabled?, outermost. - Production observability is wired on an always-on surface — a frame
:observabilitysink, or the same entry grammar declared once with(rf/configure! {:observability …}). - Handlers receiving untrusted payloads carry a
:schemaand are registered:boundary? true. - A JVM/SSR tier ships with
-Dre-frame.debug=false. - No Xray preload or pair-server artefact on the release classpath.
Six checks. If every one holds, ship.