Skip to main content
This page documents known limitations and design tradeoffs in the flora runtime. Understanding these helps you design more robust bots.

Cron Jobs

No Persistence

Cron jobs are ephemeral — they exist only in memory for the lifetime of the worker process (apps/www/limitations.md:12).
StatePersisted?Behavior on restart
Job definitionsNoRe-registered when scripts reload
next_run timeNoRecalculated from current time
is_running flagNoReset to false
Execution historyNoNo audit trail

Why This Is Usually Fine

Scripts reload on startup. When the runtime starts, it loads all deployments from the database and executes them (apps/runtime/src/main.rs:149-157). Your cron() calls run again, re-registering all jobs. Schedules resume, not catch up. After a restart, cron jobs calculate their next run from “now” rather than trying to execute missed runs. For most Discord bot use cases (reminders, periodic cleanups, status updates), this is the expected behavior. Redeployment clears old jobs. When you deploy a new version of your script, the runtime clears all cron jobs for that guild before loading the new script (apps/runtime/src/runtime/worker.rs:152-217). This prevents stale jobs from lingering.

Edge Cases

Duplicate execution on crash: If the runtime crashes while a cron job is executing, the is_running flag is lost. On restart, the job may run again if it’s due. Use skipIfRunning: true and design handlers to be idempotent:
cron('daily-cleanup', '0 0 * * *', async () => {
  // This handler should be safe to run twice
  await cleanupOldMessages()
}, { skipIfRunning: true })
Schedule drift: If the runtime is down for an extended period, jobs won’t “catch up” on missed executions. A job scheduled for midnight won’t run at 2 AM if the bot was down at midnight — it will wait for the next midnight.

When You Need More

If your use case requires:
  • Exactly-once execution with audit logs
  • Catch-up runs after downtime
  • Non-idempotent side effects (billing, one-time notifications)
Consider implementing your own persistence layer using the KV store (apps/www/limitations.md:53-68):
cron('critical-job', '0 * * * *', async () => {
  const lastRun = await kv.get<string>('critical-job:last-run')
  const now = new Date().toISOString()

  // Check if we already ran this hour
  if (lastRun && isSameHour(lastRun, now)) {
    return
  }

  await performCriticalWork()
  await kv.set('critical-job:last-run', now)
})

Guild-Only Scope

flora is designed for guild contexts only (apps/www/limitations.md:70-72). Direct messages and global commands are not supported.
All events without a guild_id are dropped by the runtime. See Guild-Only Scope for details.

What You Can’t Do

  • Listen for DMs to the bot
  • Register global slash commands (commands available in all guilds)
  • Handle user presence updates outside guilds
  • Access group DM events
If DMs are a core requirement, flora is not the right framework.

Isolate Limits

Each guild runs in its own V8 isolate with enforced limits (apps/www/limitations.md:75-86):
ResourceDefault LimitConfigurable via
Script size8 MBRUNTIME_MAX_SCRIPT_BYTES
Dispatch timeout3 secondsRUNTIME_DISPATCH_TIMEOUT_SECS
Cron timeout5 secondsRUNTIME_CRON_TIMEOUT_SECS
Boot timeout5 secondsRUNTIME_BOOT_TIMEOUT_SECS
Load timeout30 secondsRUNTIME_LOAD_TIMEOUT_SECS
Max cron jobs per guild32RUNTIME_MAX_CRON_JOBS
Max bundle files200RUNTIME_MAX_BUNDLE_FILES
Max bundle total bytes1 MBRUNTIME_MAX_BUNDLE_TOTAL_BYTES
These limits are enforced per-isolate to prevent resource exhaustion. Hitting a limit terminates the isolate and logs an error.

Timeout Behavior

When an event handler exceeds the dispatch timeout:
  1. The isolate’s event loop is terminated via v8::Isolate::terminate_execution()
  2. An error is logged with guild context
  3. The event is dropped
  4. The isolate remains alive for future events
// Bad: This will timeout after 3 seconds
on('messageCreate', async (msg) => {
  await new Promise(resolve => setTimeout(resolve, 10000))
  await msg.reply('Done!') // Never executes
})
Use cron jobs for long-running tasks:
// Good: Cron has its own timeout (5s)
cron('long-task', '*/5 * * * *', async () => {
  await doWork()
})

Script Size Limits

The bundled script (SDK + your code) must fit within the size limit:
# Check bundle size before deploying
flora build
# Output: Bundle size: 2.3 MB (SDK: 1.8 MB, Guild: 0.5 MB)
If you hit the limit:
  • Remove unused dependencies
  • Split code into smaller modules
  • Use dynamic imports where possible
  • Minify production builds

KV Store Limits

The KV store has constraints per key/value/store (apps/www/runtime.md:40-50):
ConstraintLimit
Value size1 MB
Key length512 characters
Store name64 characters
List default limit100
List max limit1000

Value Size

If you need to store large values (>1 MB), consider:
  • Splitting across multiple keys
  • Compressing before storing
  • Using external storage (S3, etc.) and storing URLs in KV
// Bad: May exceed 1 MB
await kv.set('huge-data', megabyteArray)

// Good: Split into chunks
for (let i = 0; i < chunks.length; i++) {
  await kv.set(`data:chunk:${i}`, chunks[i])
}

List Pagination

Listing keys is paginated:
// List first 100 keys
const page1 = await kv.list({ prefix: 'user:' })

// Get next page
const page2 = await kv.list({
  prefix: 'user:',
  cursor: page1.cursor
})
Large key spaces (>1000 keys) require multiple round trips.

Migration Limitations

Guild runtimes can be migrated between workers (apps/runtime/src/runtime/mod.rs:112-177), but:

No In-Memory State Transfer

Migration does not preserve:
  • Global variables
  • Timers (setTimeout, setInterval)
  • Open connections (WebSockets, HTTP)
  • Pending promises not tied to events
// Bad: This state is lost on migration
let cache = new Map()

on('messageCreate', (msg) => {
  cache.set(msg.id, msg.content) // Lost on migration
})

// Good: Use KV for persistent state
on('messageCreate', async (msg) => {
  await kv.set(`msg:${msg.id}`, msg.content)
})

Event Queueing

During migration, events are queued (apps/runtime/src/runtime/mod.rs:186-199). Events with high frequency may hit the queue limit (defined by MAX_DROPPABLE_BACKLOG in apps/runtime/src/runtime/constants.rs). Droppable events (e.g., typing indicators) are discarded if the queue is full.

No TypeScript Type Checking

flora bundles and executes your TypeScript code, but does not perform type checking at deploy time.
// This deploys successfully but crashes at runtime
const x: number = 'hello'
on('messageCreate', (msg) => {
  msg.reply(x.toFixed(2)) // Runtime error
})
Run tsc --noEmit locally before deploying to catch type errors:
npx tsc --noEmit && flora deploy

Secrets are Runtime-Scoped by Default

Secrets set via flora secrets set are shared across all guilds by default (apps/runtime/src/runtime/secrets.rs):
# This secret is available to ALL guilds
flora secrets set API_KEY abc123
For guild-specific secrets, use the --guild flag:
flora secrets set --guild 123456789 API_KEY xyz789
Guild-scoped secrets override runtime-scoped secrets of the same name.

No Source Maps in Production

Error stack traces point to the bundled JavaScript, not your original TypeScript:
Error: Something went wrong
  at handler (bundle.js:123:45)
  at dispatch (runtime.js:67:89)
Source maps are stored but not applied to runtime errors. This is a known limitation.

No V8 Snapshots

flora does not use V8 snapshots for faster isolate initialization (apps/runtime/src/v8_init.rs). Each isolate is initialized from scratch. Startup time is acceptable (less than 5 seconds per guild), but may be noticeable with hundreds of guilds.

No Multi-Region Deployment

flora is designed for single-region deployment. There is no built-in support for:
  • Multi-region replication
  • Global load balancing
  • Cross-region data sync
For high availability, deploy multiple instances behind a load balancer with shared Postgres/Redis.

No Built-In Rate Limiting

flora does not enforce per-guild rate limits on Discord API calls. If your script makes too many requests, Discord will rate limit your bot globally. Implement your own rate limiting using KV:
on('messageCreate', async (msg) => {
  const key = `ratelimit:${msg.author.id}`
  const count = (await kv.get<number>(key)) || 0

  if (count > 5) {
    return // Rate limited
  }

  await kv.set(key, count + 1, { ttl: 60 })
  await msg.reply('Hello!')
})

Summary

flora makes opinionated tradeoffs for simplicity and security:
  • Cron jobs are ephemeral - Use KV for critical state
  • Guild-only scope - No DM support by design
  • Strict resource limits - Prevents runaway scripts
  • No in-memory migration - Use KV for persistent state
  • No type checking at deploy - Run tsc locally
Understanding these limitations helps you design bots that work with flora’s architecture, not against it.

Build docs developers (and LLMs) love