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).
| State | Persisted? | Behavior on restart |
|---|
| Job definitions | No | Re-registered when scripts reload |
next_run time | No | Recalculated from current time |
is_running flag | No | Reset to false |
| Execution history | No | No 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):
| Resource | Default Limit | Configurable via |
|---|
| Script size | 8 MB | RUNTIME_MAX_SCRIPT_BYTES |
| Dispatch timeout | 3 seconds | RUNTIME_DISPATCH_TIMEOUT_SECS |
| Cron timeout | 5 seconds | RUNTIME_CRON_TIMEOUT_SECS |
| Boot timeout | 5 seconds | RUNTIME_BOOT_TIMEOUT_SECS |
| Load timeout | 30 seconds | RUNTIME_LOAD_TIMEOUT_SECS |
| Max cron jobs per guild | 32 | RUNTIME_MAX_CRON_JOBS |
| Max bundle files | 200 | RUNTIME_MAX_BUNDLE_FILES |
| Max bundle total bytes | 1 MB | RUNTIME_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:
- The isolate’s event loop is terminated via
v8::Isolate::terminate_execution()
- An error is logged with guild context
- The event is dropped
- 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):
| Constraint | Limit |
|---|
| Value size | 1 MB |
| Key length | 512 characters |
| Store name | 64 characters |
| List default limit | 100 |
| List max limit | 1000 |
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])
}
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.