5.1 KiB
5.1 KiB
Preload Optimization
Key concept: Handlers run TWICE - first for preloading, then for execution.
Preload optimization is HyperIndex's flagship performance feature. It reduces database roundtrips from thousands to single digits by batching reads across events.
Why Preload Exists
The Problem:
// Without preload: 5,000 Transfer events = 10,000 DB calls
ERC20.Transfer.handler(async ({ event, context }) => {
const sender = await context.Account.get(event.params.from); // DB call 1
const receiver = await context.Account.get(event.params.to); // DB call 2
});
With Preload: All 5,000 events preload concurrently, batching identical entity types into single queries. Result: 10,000 calls → 2 calls.
How It Works
Phase 1: Preload (Concurrent)
- All handlers run in parallel for the entire batch
- Database reads are batched and deduplicated
- Entity writes are SKIPPED
context.logcalls are SKIPPED- Errors are silently caught (won't crash)
Phase 2: Execution (Sequential)
- Handlers run one-by-one in on-chain order
- Reads come from in-memory cache (instant)
- Entity writes persist to database
- Logging works normally
- Errors will crash the indexer
Configuration
# config.yaml
preload_handlers: true # Default since envio@2.27
Checking Which Phase You're In
MyContract.Event.handler(async ({ event, context }) => {
// This runs in BOTH phases
const account = await context.Account.get(event.params.user);
if (context.isPreload) {
// Preload phase only - skip heavy logic
return;
}
// Execution phase only
context.log.info("Processing...");
// CPU-intensive operations
// Side effects
});
Optimize with Promise.all
Concurrent reads in preload = fewer batched queries:
// GOOD: Concurrent reads
ERC20.Transfer.handler(async ({ event, context }) => {
const [sender, receiver] = await Promise.all([
context.Account.get(event.params.from),
context.Account.get(event.params.to),
]);
// ...
});
// LESS OPTIMAL: Sequential reads
ERC20.Transfer.handler(async ({ event, context }) => {
const sender = await context.Account.get(event.params.from);
const receiver = await context.Account.get(event.params.to);
// ...
});
Critical Footguns
Never Call fetch() Directly
// WRONG - fetch runs TWICE
MyContract.Event.handler(async ({ event, context }) => {
const data = await fetch(`https://api.example.com/${event.params.id}`);
});
// CORRECT - Use Effect API
import { getMetadata } from "./effects";
MyContract.Event.handler(async ({ event, context }) => {
const data = await context.effect(getMetadata, event.params.id);
});
Never Use External APIs Without Effect API
Any external call (RPC, REST, GraphQL) must use the Effect API. See effect-api.md for details.
Side Effects Run Twice
// WRONG - Analytics call runs twice!
MyContract.Event.handler(async ({ event, context }) => {
await sendToAnalytics(event); // Called in preload AND execution
});
// CORRECT - Guard with isPreload
MyContract.Event.handler(async ({ event, context }) => {
if (!context.isPreload) {
await sendToAnalytics(event); // Only runs once
}
});
When to Use context.isPreload
Use the context.isPreload check for:
- CPU-intensive operations - Skip during preload
- Side effects that can't be rolled back - Analytics, webhooks
- Logging - Already skipped by default, but explicit if needed
- Operations that depend on previous events' writes
MyContract.Event.handler(async ({ event, context }) => {
// ALWAYS runs (both phases) - data loading
const [entity1, entity2] = await Promise.all([
context.Entity1.get(event.params.id1),
context.Entity2.get(event.params.id2),
]);
// Early return after loading in preload phase
if (context.isPreload) return;
// ONLY execution phase - actual processing
const result = expensiveCalculation(entity1, entity2);
context.Entity1.set({
...entity1,
processedValue: result,
});
});
Preload Behavior Summary
| Operation | Preload Phase | Execution Phase |
|---|---|---|
context.Entity.get() |
Batched, cached | From cache |
context.Entity.set() |
Ignored | Persisted |
context.log.*() |
Ignored | Works |
context.effect() |
Batched, cached | From cache |
| Exceptions | Silently caught | Crash indexer |
Direct fetch() |
Runs (BAD!) | Runs again (BAD!) |
Performance Impact Example
Indexing 100,000 Transfer events:
| Approach | DB Roundtrips | Time |
|---|---|---|
| No preload | 200,000 | ~10 min |
| With preload (sequential reads) | 2 | ~5 sec |
| With preload + Promise.all | 1 | ~3 sec |
Best Practices
- Place reads at handler start - Maximize preload benefit
- Use Promise.all for multiple reads - Reduce to single batch
- Use Effect API for ALL external calls - Automatic batching/caching
- Skip non-essential logic with
context.isPreload- Faster preload - Don't worry about "entity not found" - Preload is optimistic; execution phase has correct data