Skip to main content
flora uses V8 isolates to provide complete execution isolation between guilds. Each guild’s script runs in its own isolate, ensuring that bugs or malicious code in one guild cannot affect others.

What is a V8 Isolate?

A V8 isolate is an independent instance of the V8 JavaScript engine:
  • Separate heap: Each isolate has its own memory allocation
  • No shared state: Isolates cannot access each other’s variables or functions
  • Independent execution: Code in one isolate cannot interfere with another
  • Resource limits: CPU and memory usage can be controlled per isolate
V8 is the JavaScript engine that powers Chrome, Node.js, and Deno. flora uses Deno Core as a wrapper around V8.

Per-Guild Isolation

flora maintains a strict one isolate per guild model:
// apps/runtime/src/runtime/worker.rs:100
let mut guild_runtimes: HashMap<String, JsRuntimeState> = HashMap::new();
Each worker thread maintains a map of guild ID to isolate state. When an event arrives for a guild:
  1. Guild ID is extracted from the Discord event
  2. Runtime looks up the guild’s isolate in the map
  3. Event is dispatched to that specific isolate
  4. No other guild’s code can observe or intercept the event

Isolation Guarantees

PropertyIsolated?Notes
Global variablesYesEach guild has its own globalThis
Imported modulesYesModule cache is per-isolate
Timers/promisesYesEvent loop is per-isolate
MemoryYesSeparate heap per isolate
ErrorsYesUnhandled errors don’t crash other guilds
SecretsYesSecrets are injected per-guild via ops
KV storageYesKV ops filter by guild ID at runtime

Isolate Lifecycle

Initialization

When a guild script is deployed (apps/runtime/src/runtime/worker.rs:152-217):
let mut js_runtime = new_js_runtime(
    guild_id.clone(),
    http.clone(),
    kv.clone(),
    guild_secrets.clone(),
    worker_id,
    limits,
    cron_registry.clone(),
);
The new_js_runtime() function:
  1. Creates a new JsRuntime with the flora ops extension
  2. Injects guild-scoped state into OpState
  3. Executes the runtime prelude (SDK initialization code)
  4. Returns the ready-to-use isolate

Script Loading

After initialization, the guild script is loaded:
load_script_source(
    &mut js_runtime,
    deployment.bundle,
    deployment.entry_path,
    worker_id,
    limits,
)
.await?;
This:
  • Evaluates the bundled JavaScript code
  • Registers event handlers via on() and slash commands via slash()
  • Extracts the dispatch function for later event routing
  • Runs the event loop until quiescence (all pending promises resolved)
If script loading exceeds load_timeout_secs (default 30s), the isolate is terminated and an error is returned.

Event Dispatch

When a Discord event arrives for the guild:
let result = dispatch_event(
    &mut state,
    event_type,
    payload,
    worker_id,
    guild_id,
    &limits,
)
.await;
The dispatch function:
  1. Enters the isolate’s V8 context
  2. Calls the cached dispatch function with the event data
  3. Runs the event loop with a timeout
  4. Returns control to the worker thread

Termination

Isolates are terminated when:
  • A new deployment replaces the guild’s script
  • The bot leaves the guild
  • A fatal error occurs (e.g., repeated timeouts)
  • The worker thread shuts down
Termination uses v8::Isolate::terminate_execution() to forcefully stop all pending operations.

Resource Limits

Each isolate is subject to configurable limits:
pub struct RuntimeLimits {
    pub boot_timeout: RuntimeTimeout,
    pub load_timeout: RuntimeTimeout,
    pub dispatch_timeout: RuntimeTimeout,
    pub cron_timeout: RuntimeTimeout,
    pub max_script_bytes: usize,
    pub max_cron_jobs: usize,
    pub migration_timeout: RuntimeTimeout,
}

Timeout Enforcement

Timeouts are enforced using Tokio’s timeout() wrapper:
pub async fn with_timeout<F, T>(
    future: F,
    timeout: &RuntimeTimeout,
    operation: &str,
) -> Result<T, AnyError>
where
    F: Future<Output = T>,
{
    match timeout {
        RuntimeTimeout::Disabled => Ok(future.await),
        RuntimeTimeout::Enabled(duration) => {
            tokio::time::timeout(*duration, future)
                .await
                .map_err(|_| AnyError::msg(format!("{} timed out", operation)))
        }
    }
}
If a timeout occurs, the isolate’s event loop is terminated via terminate_execution().

Script Size Limits

The bundled script size is checked before loading:
if bundle.len() > limits.max_script_bytes {
    return Err(AnyError::msg(format!(
        "script size {} exceeds limit {}",
        bundle.len(),
        limits.max_script_bytes
    )));
}
Default limit: 8 MB (configurable via RUNTIME_MAX_SCRIPT_BYTES).

Worker Thread Model

Workers use a single-threaded Tokio runtime per worker thread (apps/runtime/src/runtime/worker.rs:94-97):
let rt = Builder::new_current_thread()
    .enable_all()
    .build()
    .expect("failed to build worker runtime");

Why Single-Threaded?

V8 isolates are not thread-safe. They can only be accessed from one thread at a time. Using a current-thread runtime ensures:
  • No race conditions on isolate state
  • No need for complex locking
  • Predictable execution order
  • Lower overhead than multi-threaded runtime

Worker Pool Scaling

To handle more guilds, increase the worker pool size:
[runtime]
max_workers = 8  # Up to 64 workers supported
Each worker can host multiple guild isolates. The runtime uses consistent hashing to distribute guilds across workers.

Shared State via Ops

While isolates are isolated, they can access shared services via ops (operations):
#[op2(async)]
pub async fn op_kv_get(
    state: Rc<RefCell<OpState>>,
    #[string] key: String,
) -> Result<Option<serde_json::Value>, JsErrorBox> {
    let guild_id = {
        let state = state.borrow();
        state.borrow::<String>().clone() // Guild ID from OpState
    };
    let kv = {
        let state = state.borrow();
        state.borrow::<KvService>().clone()
    };
    // ...
}
Ops extract guild-scoped state from OpState to ensure proper isolation:
  • Guild ID: Injected when the isolate is created
  • HTTP client: Shared Arc<Http> for Discord API calls
  • KV service: Shared KvService that filters by guild ID
  • Secrets: Guild-scoped secrets from SecretService
Ops are the only way for isolate code to interact with the outside world. This provides a security boundary.

Default Runtime

Each worker also maintains a default runtime (apps/runtime/src/runtime/worker.rs:101):
let mut default_runtime: Option<JsRuntimeState> = None;
The default runtime:
  • Hosts the SDK bundle (loaded once per worker)
  • Does not have a guild ID
  • Used for testing and SDK initialization
  • Shares the same ops as guild runtimes (but with no guild context)

Migration Between Workers

Guild runtimes can be migrated between workers for load balancing (apps/runtime/src/runtime/mod.rs:112-177):
  1. Source worker serializes the deployment state into a MigrationEnvelope
  2. Source worker terminates the guild isolate
  3. Target worker receives the envelope
  4. Target worker creates a new isolate with the guild’s script
  5. Queued events are replayed on the new isolate
Migration is atomic: if the target worker fails to load the script, the source worker restores the original isolate (apps/runtime/src/runtime/mod.rs:143-159).
Migration does not preserve in-memory state (global variables, timers, etc.). Only the deployment (script code) is transferred.

Best Practices

Avoid Global Mutable State

Global variables are reset when the script is redeployed:
// Bad: This counter resets on every deployment
let messageCount = 0

on('messageCreate', () => {
  messageCount++
})

// Good: Use KV for persistent state
on('messageCreate', async () => {
  const count = (await kv.get('messageCount')) || 0
  await kv.set('messageCount', count + 1)
})

Handle Timeouts Gracefully

Long-running operations will time out:
// Bad: This will time out after 3 seconds
on('messageCreate', async (msg) => {
  await new Promise(resolve => setTimeout(resolve, 10000))
})

// Good: Use cron for long-running tasks
cron('background-task', '*/5 * * * *', async () => {
  // Runs every 5 minutes with 5s timeout
})

Use Secrets for Sensitive Data

Secrets are injected per-guild and never logged:
// Get guild-scoped secret
const apiKey = await secrets.get('API_KEY')

// Use in API call
const response = await fetch('https://api.example.com', {
  headers: { Authorization: `Bearer ${apiKey}` }
})

Build docs developers (and LLMs) love