Fict includes built-in cycle protection to identify and stop infinite reactive loops. Development and canary builds can use configurable low thresholds and early warnings; every build, including production, retains a non-disableable high-threshold hard guard so a dynamic cycle cannot spin forever.
Reactive systems can accidentally create infinite loops when:
- An effect updates a signal that it depends on
- Multiple effects form a circular dependency chain
- Component re-renders trigger effects that cause more re-renders
Fict's cycle protection monitors these patterns and provides helpful warnings in development mode. Production keeps the hard guard active even when the configurable diagnostic tier is disabled.
The configurable guard is enabled by default only in development mode (NODE_ENV !== 'production'). Production defaults to immutable ceilings of 100,000 effect runs per flush and 100 re-entries of the same root. setCycleProtectionOptions({ enabled: false }) cannot disable those ceilings. Internal canaries should enable the configurable tier explicitly and use lower thresholds so a regression is detected before the production hard limit.
Detection triggers in three scenarios:
| Trigger | Description |
|---|---|
| Flush Budget Exceeded | Too many effects ran in a single microtask flush |
| Root Re-entry Depth | A root context was re-entered too deeply (recursive component execution) |
| High Usage Window | Multiple consecutive flush cycles used a high percentage of the budget |
Configure cycle protection thresholds and behavior.
import { setCycleProtectionOptions } from 'fict/advanced'
interface CycleProtectionOptions {
/** Enable configurable guards and warnings (default: dev-only). Hard limits remain active. */
enabled?: boolean
/** Maximum effect runs allowed per microtask flush (default: 10,000) */
maxFlushCyclesPerMicrotask?: number
/** Maximum effect runs per flush (default: 20,000) */
maxEffectRunsPerFlush?: number
/** Number of flushes to track for high-usage detection (default: 5) */
windowSize?: number
/** Ratio threshold for high-usage warning (default: 0.8 = 80%) */
highUsageRatio?: number
/** Maximum root re-entry depth before warning (default: 10) */
maxRootReentrantDepth?: number
/** Whether to warn about sustained high usage patterns (default: true) */
enableWindowWarning?: boolean
/** If true, throw errors instead of warnings (useful for testing) */
devMode?: boolean
/** Enable threshold warnings before hard limit (default: dev-only true) */
enableBackoffWarning?: boolean
/** First warning threshold ratio (default: 0.5 = 50%) */
backoffWarningRatio?: number
}
setCycleProtectionOptions(options: CycleProtectionOptions): voidThe immutable ceilings are intentionally not options. Non-finite or oversized configured values are bounded to them, and the high-usage window is bounded to 100 entries.
Example:
import { setCycleProtectionOptions } from 'fict/advanced'
// Increase limits for a large application
setCycleProtectionOptions({
maxFlushCyclesPerMicrotask: 50000,
maxEffectRunsPerFlush: 100000,
})
// Strict mode for testing - throw errors instead of warnings
setCycleProtectionOptions({
devMode: true,
maxFlushCyclesPerMicrotask: 100,
})
// Recommended internal canary profile
setCycleProtectionOptions({
enabled: true,
devMode: false,
maxFlushCyclesPerMicrotask: 10000,
maxEffectRunsPerFlush: 20000,
enableWindowWarning: true,
})Default: NODE_ENV !== 'production'
Controls the configurable diagnostic tier. It is enabled by default in development and disabled by default in production. The immutable production hard guard remains active in both cases. Enable this tier in canaries to enforce lower thresholds and high-usage warnings:
setCycleProtectionOptions({ enabled: true })Default: 10,000
Maximum number of effect runs allowed within a single microtask flush. If exceeded, cycle protection will warn (or throw in devMode) and stop processing further effects.
Values above the immutable 100,000-run ceiling are clamped. Setting
enabled: false selects that ceiling rather than disabling cycle safety.
When to adjust:
- Increase for large applications with many reactive computations
- Decrease for stricter cycle detection during development
// Large app with complex reactive graph
setCycleProtectionOptions({
maxFlushCyclesPerMicrotask: 50000,
})
// Strict development mode
setCycleProtectionOptions({
maxFlushCyclesPerMicrotask: 100,
})Default: 20,000
Similar to maxFlushCyclesPerMicrotask, but tracks total effect runs across the flush. This provides a secondary limit.
The effective limit is the lower of the two configured effect limits and the immutable 100,000-run ceiling.
Default: 5
Number of consecutive flush cycles to track for high-usage pattern detection. The window tracks how much of the budget each flush used.
The retained window is capped at 100 entries. A sustained high-usage episode emits once; telemetry is re-armed only after usage drops below the threshold.
Default: 0.8 (80%)
When all flushes in the window use more than this ratio of their budget, a warning is triggered. This helps detect sustained high load that might indicate a performance problem.
Example:
If windowSize is 5 and highUsageRatio is 0.8, a warning triggers when 5 consecutive flushes each use 80% or more of the budget.
// More sensitive to sustained high usage
setCycleProtectionOptions({
windowSize: 3,
highUsageRatio: 0.6, // Warn at 60% usage over 3 flushes
})Default: 10
Maximum depth of nested root context re-entry. This detects recursive component execution patterns that could indicate infinite loops.
Values above 100 are clamped to the immutable production ceiling.
// Stricter recursive detection
setCycleProtectionOptions({
maxRootReentrantDepth: 5,
})Default: true
Toggle the high-usage window warning. Set to false to disable sustained usage warnings while keeping other protections active.
// Disable sustained usage warnings
setCycleProtectionOptions({
enableWindowWarning: false,
})Default: NODE_ENV !== 'production'
When true, cycle protection throws errors instead of logging warnings. Useful for:
- Unit tests that should fail on cycle detection
- Strict development environments
- CI/CD pipelines
// For testing - fail fast on cycles
setCycleProtectionOptions({
devMode: true,
maxFlushCyclesPerMicrotask: 100,
})Cycle protection integrates with the Fict DevTools hook. When a cycle is detected, the cycleDetected method is called with details:
interface CycleDetectedPayload {
reason: 'flush-budget-exceeded' | 'root-reentry' | 'high-usage-window'
detail?: {
effectRuns?: number
limit?: number
hardLimit?: boolean
depth?: number
windowSize?: number
ratio?: number
}
}DevTools can subscribe to these events for enhanced debugging visualization. The payload is bounded to numeric/boolean counters and never includes component names, source text, reactive values, or a dependency graph. Observer and console failures are contained and cannot weaken queue dropping or root re-entry blocking.
Problem:
function Counter() {
let count = $state(0)
// ❌ Infinite loop: effect reads and writes count
$effect(() => {
console.log(count)
count++ // Updates count, which re-triggers the effect
})
return <div>{count}</div>
}Solution:
function Counter() {
let count = $state(0)
// ✅ Use untrack to break the dependency
$effect(() => {
const current = count // Track read
console.log(current)
// Don't update the same signal in the effect
})
return <div>{count}</div>
}Problem:
function Form() {
let a = $state(0)
let b = $state(0)
// ❌ Effects trigger each other
$effect(() => {
b = a + 1
})
$effect(() => {
a = b + 1
})
return (
<div>
{a} {b}
</div>
)
}Solution:
function Form() {
let a = $state(0)
// ✅ Compute derived value instead of circular effects
const b = a + 1 // Automatically derived
return (
<div>
{a} {b}
</div>
)
}Problem:
function List() {
let items = $state([])
// ❌ Effect runs fetch which updates items, triggering re-execution
$effect(() => {
fetch('/api/items')
.then(r => r.json())
.then(data => {
items = data // This can cause re-execution if items is read in control flow
})
})
if (items.length > 0) {
// Reading items in control flow...
}
return (
<ul>
{items.map(i => (
<li key={i.id}>{i.name}</li>
))}
</ul>
)
}Solution:
import { resource } from 'fict/plus'
// ✅ Use resource for data fetching
const itemsResource = resource(async ({ signal }) => {
const res = await fetch('/api/items', { signal })
return res.json()
})
function List() {
const items = itemsResource.read()
if (items.loading) return <div>Loading...</div>
return (
<ul>
{items.data?.map(i => (
<li key={i.id}>{i.name}</li>
))}
</ul>
)
}For unit tests, enable strict mode to fail fast on cycle detection:
import { describe, it, expect, afterEach } from 'vitest'
import { setCycleProtectionOptions } from 'fict/advanced'
import { resetCycleProtectionStateForTests } from '@fictjs/runtime/internal'
describe('Reactive Tests', () => {
afterEach(() => {
// Reset state between tests
resetCycleProtectionStateForTests()
})
it('should not create infinite loops', () => {
setCycleProtectionOptions({
devMode: true,
maxFlushCyclesPerMicrotask: 100,
})
// Test code...
// Will throw if cycle is detected
})
})Cycle protection warnings follow this format:
[fict] cycle protection triggered: <reason>
| Reason | Description |
|---|---|
flush-budget-exceeded |
Too many effects ran in one flush |
root-reentry |
Root context re-entered too deeply |
high-usage-window |
Sustained high usage over multiple flushes |
Each warning includes context to help identify the issue:
// Example warning output
[fict] cycle protection triggered: flush-budget-exceeded { effectRuns: 10001 }
[fict] cycle protection triggered: root-reentry { depth: 11 }
[fict] cycle protection triggered: high-usage-window { windowSize: 5, ratio: 0.8 }In production (NODE_ENV === 'production'), cycle protection is disabled by default for maximum performance. You can opt in:
setCycleProtectionOptions({ enabled: true })When disabled (default):
- All cycle protection guards are no-ops
- Zero runtime overhead
- No warnings are emitted
- Application continues even if cycles would occur
When enabled:
- Guards run with the configured thresholds
devModedefaults toNODE_ENV !== 'production'(throw in dev, warn in prod)
-
Keep default settings for most applications - The defaults are tuned for typical use cases
-
Enable devMode in tests - Catch cycles early in your test suite
-
Don't suppress warnings - Cycle warnings indicate real problems
-
Use derived values instead of circular effects - Let the compiler handle dependencies
-
Use
resourcefor async data - Avoids effect/state update cycles -
Review high-usage warnings - They may indicate performance issues even if not infinite loops
- Architecture - How Fict's reactive system works
- Reactivity Semantics - Rules of the reactive system
- API Reference - Complete API documentation