Performance Optimization
Adama can handle a lot of work, but you can absolutely make it slow if you're not thinking about how the runtime actually operates. This chapter is about the things that matter -- document sizing, query patterns, formula costs, memory -- and how to avoid the mistakes I've seen (and made) most often.
Document Sizing Considerations
The document is the fundamental unit of everything in Adama -- computation, persistence, isolation. Getting the size right is one of those decisions that's hard to undo later, so it's worth thinking about early.
The Goldilocks Problem
Documents should be large enough to contain related state but small enough to fit comfortably in memory and persist without pain.
Too Small: If you make a separate document for every tiny piece of state, you lose the whole reactive model. Cross-document coordination is painful, and you can't use formulas to compute derived values across boundaries. That's throwing away Adama's best feature.
// Anti-pattern: One document per message in a chat
// This loses the ability to query messages together
Too Big: If you cram everything into one document, you'll hit memory limits, persistence gets slow, and every user is competing for the same sequential message queue. One big document becomes one big bottleneck.
// Anti-pattern: All games on your platform in one document
// This creates a bottleneck and massive state size
Just Right: Group state by natural boundaries -- one document per game session, one per chat room, one per user profile. If the data belongs together conceptually and needs to be queried together, it belongs in one document.
Sizing Guidelines
| Document Size | Records | Use Case |
|---|---|---|
| Small (< 100KB) | Tens of records | Single user state, simple games |
| Medium (100KB - 10MB) | Hundreds to thousands | Multiplayer games, chat rooms |
| Large (10MB - 100MB) | Tens of thousands | Complex simulations, large datasets |
Documents over 100MB are asking for trouble. If you need more data, split across multiple documents or use external storage for large assets. Don't try to be a hero here.
Measuring Document Size
The persisted size is the serialized JSON of all state. You can estimate it:
- Each field contributes its serialized size (integers are small, strings vary)
- Each record in a table adds overhead plus its field sizes
- Formulas are not stored (they're recomputed) -- that's free
record ChatMessage {
public int id; // ~10 bytes
public string content; // variable, typically 50-500 bytes
public principal sender; // ~50 bytes
public datetime sent_at; // ~25 bytes
}
// 1000 messages * ~200 bytes average = ~200KB
table<ChatMessage> messages;
State Management Strategies
For documents that might grow unbounded, implement trimming. Otherwise you're just building a memory leak with extra steps.
record LogEntry {
public int id;
public string message;
public datetime timestamp;
}
table<LogEntry> logs;
// Keep only the most recent 1000 entries
procedure trimLogs() {
int excess = logs.size() - 1000;
if (excess > 0) {
(iterate logs order by timestamp asc limit excess).delete();
}
}
Call trimLogs() after adding new entries. Your future self will thank you.
Query Optimization
Queries are how you get at data in tables. Getting them right is the difference between "snappy" and "why is this slow."
Use Graph Indexing for Cross-Table Relationships
When you're frequently traversing relationships between tables -- finding all tags for a set of users, all groups a person belongs to -- use assoc/join/traverse instead of manual iteration. Graph indexing precomputes relationship maps and updates them incrementally; no full table scans on every query.
record User {
public int id;
public string name;
}
record Group {
public int id;
public string name;
}
record Membership {
int user_id;
int group_id;
}
table<User> _users;
table<Group> _groups;
table<Membership> _memberships;
assoc<User, Group> in_group;
join in_group via _memberships[m] from m.user_id to m.group_id;
// O(source + result) — no scanning of _memberships or _groups
public formula user1_groups = iterate _users where id == 1 traverse in_group;
Without graph indexing, finding user 1's groups would mean scanning _memberships for matching rows, then looking up each group -- O(memberships) per query. With graph indexing, the edges are precomputed and traversal cost is proportional only to the input and output sizes. That's a big deal when your junction table has thousands of rows.
See the Graph Indexing chapter for the full story.
Use Indexes for Equality Filters
This is the single most impactful optimization. If you're filtering on a field with ==, index it.
record Player {
public int id;
public string name;
public int teamId;
public bool active;
index teamId; // Add index for frequent team lookups
index active; // Add index for status filtering
}
table<Player> players;
// Fast: Uses teamId index
public formula teamPlayers = iterate players where teamId == 42;
// Fast: Uses active index
public formula activePlayers = iterate players where active == true;
// Fast: Uses both indexes
public formula activeTeamPlayers = iterate players
where teamId == 42 && active == true;
Without indexes, every query scans the entire table. With indexes, Adama jumps directly to matching records. The difference on a table with 10,000 rows is... substantial.
Index Limitations
Here's the thing: indexes only help with equality comparisons (==). Range comparisons don't use indexes.
record Item {
public int id;
public int price;
index price; // This index will NOT help range queries
}
table<Item> items;
// SLOW: Range queries cannot use indexes
public formula expensive = iterate items where price > 100;
// FAST: Equality queries use the index
public formula priced_at_100 = iterate items where price == 100;
If you need efficient range queries, consider bucketing:
record Item {
public int id;
public int price;
public string priceRange; // "low", "medium", "high"
index priceRange;
}
table<Item> items;
// Now we can efficiently query by price range
public formula expensiveItems = iterate items where priceRange == "high";
It's a bit ugly, but it works.
Query Order Matters
Put the most selective filter first when combining conditions:
record Player {
public int id;
public int teamId;
public bool active;
index teamId;
index active;
}
table<Player> players;
// If 10% of players are active and 50% are on team 42...
// Better: Start with the more selective condition
public formula better = iterate players where active == true && teamId == 42;
// Less optimal: Starts with less selective condition
public formula lessOptimal = iterate players where teamId == 42 && active == true;
The optimizer handles some cases, but explicit ordering helps ensure predictable performance. Don't rely on the optimizer being smart -- help it out.
Limit Early
When you only need a subset, say so. Don't process a thousand records to show ten.
record Player {
public int id;
public int score;
}
table<Player> players;
// Good: Only processes top 10
public formula topScorers = iterate players
order by score desc
limit 10;
// Wasteful if you only display 10: Processes all players
public formula allByScore = iterate players
order by score desc;
Avoid Expensive Operations in Filters
The where clause runs for every record considered. Keep it cheap.
record Item {
public int id;
public string category;
}
table<Item> items;
record AllowedTag {
public int id;
public string value;
}
table<AllowedTag> allowedTags;
// Fast: Simple field comparison
public formula fast = iterate items where category == "electronics";
// Slower: String operations for every record
public formula slower = iterate items where category.startsWith("elec");
If complex filtering is unavoidable, consider precomputing the filter result into a boolean field. Trade a bit of storage for a lot of query speed.
Formula Complexity
Formulas are lazily evaluated -- they only compute when someone looks at them. That's powerful, but there are tradeoffs worth understanding.
Lazy Evaluation Benefits
Unused formulas cost nothing. Zero. Nada.
record LargeRecord {
public int id;
public double value;
}
table<LargeRecord> largeTable;
// These formulas only compute if a viewer accesses them
public formula expensiveMetric1 = (iterate largeTable).value.sum();
public formula expensiveMetric2 = (iterate largeTable).size();
public formula expensiveMetric3 = largeTable.size();
// If a client only subscribes to expensiveMetric1, the others never run
This is one of the beautiful things about lazy evaluation -- you can define all the formulas you want and only pay for the ones that get used.
Caching and Invalidation
Formula results are cached until their dependencies change:
public int x = 1;
public int y = 2;
public formula sum = x + y;
// First access: computes 1 + 2 = 3, caches result
// Subsequent accesses: returns cached 3
// After x = 5: cache invalidates
// Next access: computes 5 + 2 = 7, caches result
Repeated access to the same formula is cheap. But if dependencies change frequently, the formula recomputes frequently. That's the tradeoff.
Formula Chain Depth
Formulas can depend on other formulas, creating computation chains:
public int base = 10;
public formula level1 = base * 2; // Depends on base
public formula level2 = level1 + 5; // Depends on level1
public formula level3 = level2 * level2; // Depends on level2
public formula level4 = level3 / 2.0; // Depends on level3
// Changing base invalidates all four formulas
Deep chains are fine. But changing a root dependency triggers recomputation through the entire chain, so be aware of that when a field at the root is changing on every transaction.
Avoid Expensive Formulas with Frequent Invalidation
If a formula is expensive to compute and its dependencies change on every message, you might be better off maintaining the result incrementally:
record Message {
public int id;
public string content;
}
message NewMessage {
string content;
}
table<Message> messages;
// Better: Maintain incremental counters
public int totalMessages = 0;
public int totalWords = 0;
channel sendMessage(NewMessage msg) {
messages <- {content: msg.content};
totalMessages++;
totalWords += msg.content.split(" ").size();
}
// These formulas are simple and stable
public formula averageWords = totalWords / totalMessages;
Table Formula Dependencies
Formulas that iterate tables depend on all records in the table:
record Item {
public int id;
public double price;
}
table<Item> items;
// This formula recomputes when ANY item changes
public formula total = (iterate items).price.sum();
For tables with frequent updates, think about whether you really need the formula to be reactive or if an incremental counter would serve you better.
Memory Management
Documents run in a managed memory environment. Understanding what costs memory helps you stay within limits.
What Consumes Memory
| Component | Memory Impact |
|---|---|
| Primitive fields | Minimal (8-64 bytes each) |
| Strings | Proportional to length |
| Records in tables | Sum of field sizes plus overhead |
| Indexes | Proportional to indexed values |
| Formula caches | Size of computed results |
| Connection state | Per-viewer bubble results |
Reducing Memory Footprint
Trim unused data: Remove records that are no longer needed. Dead data is dead weight.
record Session {
public int id;
public datetime lastActive;
}
table<Session> sessions;
// Clean up old sessions (inactive for more than 1 hour)
procedure cleanSessions() {
datetime cutoff = Time.datetime().past(@timespan 1 hr);
(iterate sessions where lastActive < cutoff).delete();
}
Limit string sizes: Validate and truncate long strings. Users will paste entire novels into text fields if you let them.
message SetDesc {
string text;
}
public string description;
channel setDescription(SetDesc msg) {
if (msg.text.length() > 1000) {
if (msg.text.left(1000) as truncated) {
description = truncated;
}
} else {
description = msg.text;
}
}
Use appropriate types: Don't store as strings what can be numbers or enums.
// Wasteful: String status
record TaskV1 {
public string status; // "pending", "active", "done"
}
// Better: Enum status
enum TaskStatus { Pending, Active, Done }
record TaskV2 {
public TaskStatus status;
}
Avoid duplicate data: Use references instead of copying.
// Wasteful: Duplicate player data in every score record
record ScoreV1 {
public string playerName; // Duplicated
public string playerTeam; // Duplicated
public int points;
}
// Better: Reference the player by id
record ScoreV2 {
public int playerId;
public int points;
}
Memory Limits
Documents have soft and hard memory limits. When you approach them:
- The runtime may slow down garbage collection
- Write operations may be throttled
- At hard limits, operations fail
Design your application to stay well within limits during normal operation. Hitting the ceiling in production is not a fun debugging session.
Concurrent Connections
Documents can handle many simultaneous connections, but each one has overhead.
Connection Costs
Each connected viewer:
- Maintains a WebSocket connection
- Has a personalized view of the document (bubbles)
- Receives delta updates when their view changes
- Consumes server resources proportional to their view complexity
Optimizing for Many Viewers
Simplify bubbles: Complex bubble queries multiply by viewer count. If you have 1000 viewers, that's 1000 evaluations of each bubble.
record Item {
public int id;
public principal owner;
public int rank;
}
table<Item> items;
// Expensive with 1000 viewers: complex queries per viewer
bubble myComplexView = iterate items
where_as item: item.owner == @who
order by rank desc;
// Better: Simpler query, let clients sort
bubble myItems = iterate items where owner == @who;
Reduce view state: Each viewer state variable adds overhead.
// Many view variables create overhead
view int pageNumber;
view int pageSize;
view string sortField;
view string filterCategory;
view string searchQuery;
view bool showArchived;
// Consider if all are necessary
Batch updates: Rapid changes create many deltas.
record Player {
public int id;
public int score;
index id;
}
table<Player> players;
message ScoreUpdate {
int amount;
}
// Better: Batch into one transaction (default behavior in channels)
channel updateScores(ScoreUpdate msg) {
// All changes in one channel handler are batched
foreach (p in iterate players) {
p.score += msg.amount;
}
}
Connection Limits
| Scenario | Typical Limit |
|---|---|
| Viewers per document | Thousands |
| Documents per server | Thousands |
| Total connections | Limited by server resources |
If you need more connections to a single logical entity, consider read replicas or document sharding.
When to Split Documents
Knowing when one document should become many is one of the harder architectural decisions. Here's how I think about it.
Signs You Should Split
Natural boundaries exist: Users, games, chat rooms, organizations -- these are natural document boundaries. If you're fighting the model, you probably have the wrong boundaries.
// Good: Separate documents for natural entities
Space: "games"
+-- Document: "game-abc" (one chess match)
+-- Document: "game-def" (another chess match)
+-- Document: "game-ghi" (another chess match)
Update contention is high: If unrelated operations compete for the same document, split them.
// Problematic: All users in one document
// Alice updating her profile blocks Bob updating his
// Better: One document per user profile
Space: "profiles"
+-- Document: "alice"
+-- Document: "bob"
+-- Document: "carol"
Memory limits are reached: If one document can't fit your data, that's a pretty clear signal.
Privacy boundaries exist: Different documents can have completely different access rules.
Signs You Should NOT Split
Data needs to be queried together: If you frequently need to join data, keep it in one document. Cross-document queries don't exist (by design).
// Keep together: Game state that's queried together
record Player {
public int id;
public string name;
}
record Card {
public int id;
public int owner;
}
record Move {
public int id;
}
table<Player> players;
table<Card> deck;
table<Move> history;
public int currentPlayer;
// These need to be in the same document for reactive formulas
public formula currentPlayerCards = iterate deck where owner == currentPlayer;
Transactions must be atomic: Cross-document transactions don't exist. If two things need to succeed or fail together, they belong in the same document.
record Account {
public int id;
public int balance;
index id;
}
table<Account> accounts;
// Must be in same document: Atomic transfer
procedure transfer(int fromId, int toId, int amount) {
(iterate accounts where id == fromId).balance -= amount;
(iterate accounts where id == toId).balance += amount;
// Both changes commit together or neither does
}
Real-time sync is required: The delta protocol works within documents. If two users need to see the same state updating in real time, that state needs to be in one document.
Splitting Strategies
Horizontal partitioning: Same schema, different data.
Space: "chats"
+-- Document: "room-general" (messages for general)
+-- Document: "room-support" (messages for support)
+-- Document: "room-random" (messages for random)
Vertical partitioning: Different schemas for different concerns.
Space: "user-profiles" (user settings, preferences)
Space: "user-activity" (user action logs, metrics)
Space: "user-content" (user-created content)
Hierarchical partitioning: Documents that coordinate.
Space: "lobbies"
+-- Document: "main-lobby" (player matchmaking)
Space: "games"
+-- Document: "game-001" (actual game, created from lobby)
+-- Document: "game-002"
Performance Checklist
A quick reference for when you're reviewing your application.
Document Design
- [ ] Documents are sized appropriately for their use case
- [ ] Unbounded growth is prevented with trimming
- [ ] Natural boundaries are respected
Queries
- [ ] Cross-table relationships use graph indexing (
assoc/join/traverse) - [ ] Frequently filtered fields are indexed
- [ ] Queries use equality conditions where possible
- [ ] Limits are applied when full results aren't needed
- [ ] Where clauses use simple expressions
Formulas
- [ ] Expensive formulas are only computed when needed
- [ ] Frequently invalidated formulas are kept simple
- [ ] Deep formula chains are reviewed for necessity
Memory
- [ ] Old data is cleaned up
- [ ] Strings are bounded in size
- [ ] Appropriate types are used (enums vs strings)
Connections
- [ ] Bubbles are as simple as possible
- [ ] View state is minimized
- [ ] Rapid updates are batched
Architecture
- [ ] Documents split along natural boundaries
- [ ] Related data that must be queried together stays together
- [ ] Atomic operations are within single documents
The Adama runtime handles a lot of complexity for you. But understanding these principles -- document sizing, query patterns, formula costs, memory awareness -- is what separates an application that stays responsive under load from one that falls over at the worst possible moment.