Config
Database
EVM JSON-RPC cache
AI agents: fetch https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt for the complete machine-readable version of this page (full configuration schema, defaults, worked examples, and source links). Append `.llms.txt` to any docs URL for the same treatment.AIFor agents: /config/database/evm-json-rpc-cache.llms.txt

Cache policies

Every repeated upstream call costs money and latency. eRPC intercepts each EVM JSON-RPC request, matches it against your cache policies, and serves the stored response — no extra round-trip. Finalized data is cached forever; realtime data is age-gated against the actual block timestamp so stale chain-tip responses never escape.

What you get

  • Parallel fan-out reads across multiple backends — fastest hit wins, peers cancel
  • Four finality buckets (finalized, unfinalized, realtime, unknown) each with independent TTLs
  • Transparent zstd compression on writes, automatic decompression on reads
  • Per-request bypass directive (X-ERPC-Skip-Cache-Read) for cache-busting without config changes

Quick taste

Illustrative, not a tuned production config — cache finalized data forever in memory:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: mem        driver: memory        memory: { maxItems: 100000 }    policies:      - connector: mem        network: "*"        method: "*"        # finalized blocks are reorg-immune — cache them forever        finality: finalized        # 0 = no expiry; safe because finalized data never changes        ttl: 0

Agent reference

Copy one of these prompts into your AI agent session (Claude Code, Cursor, …) — each one points the agent at this page's machine-readable reference so it can do the work correctly:

Prompt Example #1: set up caching from scratch
I want to stop paying for repeated upstream calls by enabling eRPC's EVM JSON-RPC cache.
Set up a four-bucket cache (finalized, unfinalized, realtime, unknown) with an in-memory
connector so every finality class is covered. Work with my existing eRPC config. Read the full
reference first: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #2: add a persistent Redis tier behind in-memory
I already have an in-memory cache connector in my eRPC config but I want to add Redis as a
warm durable fallback so cached data survives restarts. Wire both connectors to the same
finalized and unfinalized policies so the memory connector wins fan-out races when hot.
Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #3: debug realtime cache misses (eth_blockNumber always bypasses cache)
My eRPC cache seems to have zero hits for eth_blockNumber and eth_call with "latest".
Audit my cache policies in my eRPC config and explain why realtime methods are missing, then
add the correct finality: realtime policy with an appropriate TTL. Reference:
https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #4: limit cache to a specific method allowlist to avoid wasted connector RTTs
I want to restrict my cache policies so only the methods my gRPC connector actually
indexes are tried (eth_getBlockByNumber, eth_getBlockByHash, eth_getBlockReceipts,
eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt). Requests for other
methods should skip the cache entirely. Update my eRPC config accordingly. Reference:
https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Prompt Example #5: shadow-write to a new connector before enabling reads
I want to migrate to a new PostgreSQL cache connector without risking stale reads. Set up
a shadow-write policy (appliesTo: set) on the new connector while keeping reads on the
existing one, so I can verify data populates correctly before cutting over. My config is
in my eRPC config. Reference:
https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt
Cache policies — full agent referenceExpand for every option, default, and edge case — or copy this entire section into your AI assistant.

How it works

Policy matching pipeline. At startup eRPC builds an ordered slice of CachePolicy objects from evmJsonRpcCache.policies. Every request passes through findGetPolicies (reads) or findSetPolicies (writes). Both walk the slice and test each policy's network, method, params, and finality fields against the request.

For reads, finality matching is asymmetric: a finalized request also matches unfinalized policies (a block finalized after write may be stored under the unfinalized policy); a realtime request matches only realtime policies; an unknown-finality request matches all policies. For writes, finality must match exactly. findGetPolicies deduplicates by connector so each connector is probed at most once per request.

Cache key derivation. Every lookup computes a partition key {networkId}:{blockRef} and a range key {method}:{sha256(params)}. The block reference comes from ExtractBlockReferenceFromRequest, which inspects the per-method reqRefs path config. If the block reference resolves to * (a wildcard tag like "latest"), the connector is queried via a reverse index rather than the main index. If the block reference is empty — method not recognized or not cacheable — the whole cache lookup is skipped silently.

Get fan-out. Get spawns one goroutine per matching policy. All goroutines race under a shared fanCtx; the first to find an acceptable non-empty hit calls cancelFan() so peers exit without posting to metrics. Results travel through a buffered channel sized to the number of goroutines so no goroutine ever blocks after fanCtx is done — a slow peer that refuses to honour cancellation never pins user-visible latency. A 30-second backstop context wraps the fan-out when the parent carries no deadline, protecting against FD leaks from misbehaving connectors.

Set fan-out. Set fans out using sync.WaitGroup (fire-and-forget per policy). A hard 5-second write timeout is applied per connector, independent of any failsafe configuration. Responses with a JSON-RPC error field are never cached. Empty results for future blocks (beyond the network's confidence head) are never cached regardless of the empty policy setting.

Realtime freshness gate. After a cache hit is retrieved for a realtime request, shouldAcceptCachedResult compares the block's unix timestamp (extracted from the response via ExtractBlockTimestampFromResponse) against the policy TTL. This is block age, not cache-entry wall-clock age: a response cached 5 minutes ago is accepted if its block was produced 3 seconds ago; a response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s.

For responses with no block timestamp (eth_blockNumber, eth_gasPrice, eth_getLogs), the gate falls back to the connector's own latest-block timestamp via the CacheHeadReporter interface — currently implemented only by GrpcConnector, which polls every 60 seconds. If neither source is available the gate fails open (accepts the result). When age exceeds TTL the result is reclassified as ttl_rejected and the fan-out loop continues searching peer connectors. Non-realtime data (finalized, unfinalized, unknown) is never age-gated.

Finality buckets. The zero value of the Go DataFinalityState enum is finalized, so omitting finality in YAML silently creates a finalized-only policy.

FinalityMeaningRecommended TTL
finalizedPast chain's finalization horizon; reorg-immune0 (forever)
unfinalizedRecent block, still reorgable5–15 s (≈ 2× block time)
realtimeUpdates every block (eth_blockNumber, eth_gasPrice, …)1–3 s
unknownBlock reference indeterminate (tx-hash keyed)0 (key is immutable)

To cache all traffic types you need at least four policies — one per finality bucket — or the realtime/unfinalized traffic will always bypass the cache and hit upstreams.

Empty-result handling. The empty field controls three modes:

  • ignore (default) — empty results are never cached on write; cached empty results are treated as misses on read.
  • allow — cache and serve empty results like any other result.
  • only — cache and serve only empty results; non-empty responses skip this policy.

A result is "emptyish" if the raw JSON bytes are null, "", "0x", "0x0", 0, [], {}, or a hex string whose non-prefix digits are all zeros.

Skip-cache-read directive. Callers can set the X-ERPC-Skip-Cache-Read HTTP header or skip-cache-read query parameter to bypass specific connectors. The value can be "true" (skip all connectors), "false" (normal), or a glob pattern matched against connector IDs.

Compression. When evmJsonRpcCache.compression.enabled = true (the default — auto-created even when the compression: block is omitted), values are compressed with zstd before storage. Only values whose pre-compression size meets or exceeds threshold (default 1024 bytes) are compressed; smaller values are stored raw. If the compressed output is larger than the original it is also stored raw. Detection on read is by the zstd magic bytes 0x28 0xB5 0x2F 0xFD — no explicit flag needed.

Connector failsafe wrapping. When a connector config lists failsafeForGets or failsafeForSets, the raw connector is wrapped in a FailsafeConnector. Retry, static-delay hedge, and circuit-breaker are supported. Consensus and hedge quantile mode are not supported at this scope.

gRPC connector (BDS read-through). GrpcConnector is a read-only connector backed by BDS gRPC servers. It does not support Set. Supported methods: eth_getBlockByNumber, eth_getBlockByHash, eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt, eth_getBlockReceipts, eth_chainId, eth_blockNumber. eth_blockNumber is derived by fetching the latest block internally — there is no native BDS gRPC call for it. The gRPC connector performs a fast-miss check: if the request block number is below the connector's earliest known block, it returns ErrRecordNotFound immediately without a network call.

CacheHash derivation (range key). The range key {method}:{sha256(params)} is computed by JsonRpcRequest.CacheHash (common/json_rpc.go:L1385-L1409). It hashes params positionally using SHA-256 over a recursive type-aware hashValue function, producing a hex string prefixed with the method name. The hash is memoized on the request object — multiple policies referencing the same request compute it only once.

reqRefs and respRefs — block reference extraction. These per-method config fields (networks[*].methods.definitions.<method>.reqRefs / respRefs) tell the block-reference extraction logic where in a JSON-RPC request or response to find a block number, tag, or hash. They determine whether a request is cacheable and what partition key to use.

Built-in path variables in common/defaults.go:

VariablePathUsed by
FirstParam[[0]]eth_getBlockByNumber, eth_getBlockByHash, …
SecondParam[[1]]eth_getStorageAt, eth_getCode, …
ThirdParam[[2]]eth_getProof
NumberOrHashParam[["number"], ["hash"]]response object fields
BlockNumberOrBlockHashParam[["blockNumber"], ["blockHash"]]receipt-like responses
ArbitraryBlock[["*"]]eth_getTransactionByHash, eth_getTransactionReceipt — tx-hash methods

Path format: each selector is an []interface{} where integers index JSON arrays and strings key into JSON objects. An empty path ([]) means the value at that position IS the scalar. A single-element path ["*"] means "arbitrary block" — produces blockRef = "*" which routes to the reverse index.

reqRefs extraction algorithm (architecture/evm/block_ref.go:L277-L310):

  1. If reqRefs has exactly one path of length 1 and that element is "*": return blockRef="*" immediately.
  2. Otherwise for each path, parse the value as a block parameter (hex number, decimal, or named tag).
  3. If multiple paths yield different block references (e.g. eth_getLogs with different fromBlock/toBlock): set blockRef = "*" (composite range).
  4. Accumulate the highest block number seen across all paths.
  5. If no path resolves: blockRef = "" → cache bypassed entirely, trace attribute cache.skip_reason = "empty_block_ref" set.

respRefs extraction algorithm (architecture/evm/block_ref.go:L343-L368): Called only after a response exists. First non-"*" block ref wins; result supplements or overrides the request-derived ref for the cache key.

Custom reqRefs/respRefs in user config override built-in defaults entirely — there is no merge.

Config schema

All fields are under database.evmJsonRpcCache. Duration values accept Go duration strings or bare integers (milliseconds).

database.evmJsonRpcCache (root)

FieldTypeDefaultBehavior / footguns
database.evmJsonRpcCache*CacheConfignilWhen nil (omitted): cache subsystem is entirely disabled — the EvmJsonRpcCache object is never created. SetDefaults on sub-fields is only called when this pointer is non-nil. The auto-generated project (id: "main", created when no projects: block exists) has NO evmJsonRpcCache config. You must provide the key explicitly to enable caching. Source: common/defaults.go:L831-836

evmJsonRpcCache.compression

Auto-created even when omitted: CacheConfig.SetDefaults always creates &CompressionConfig{} and calls its SetDefaults() when c.Compression == nil. The only way to opt out is compression.enabled: false.

FieldTypeDefaultBehavior / footguns
compression.enabled*booltrue — set by SetDefaults when nilEnable zstd compression. Footgun: omitting the block silently enables compression. Source: common/defaults.go:L580-582
compression.algorithmstring"zstd"Only "zstd" is implemented; reserved for future algorithms. Source: common/defaults.go:L585-587
compression.zstdLevelstring"fastest"Valid: "fastest", "default", "better", "best". Unknown values fall back to fastest with a warning. Source: common/defaults.go:L590-592
compression.thresholdint (bytes)1024Minimum pre-compression size to attempt compression. Footgun: the inline code default is 512 bytes but SetDefaults overrides to 1024; SetDefaults wins at runtime. Source: common/defaults.go:L597-599

evmJsonRpcCache.connectors[*]

Connector SetDefaults is called with scope = connectorScopeCache, which sets cache-specific defaults.

FieldTypeDefaultBehavior / footguns
connectors[*].idstring"cache-<driver>"Unique ID referenced by policies. Auto-set to "cache-" + driver when blank. Source: common/defaults.go:L847-849
connectors[*].driverstringrequiredOne of memory, redis, dynamodb, postgresql, grpc.
connectors[*].memory.maxItemsintrequiredMax items in Ristretto in-process cache.
connectors[*].memory.maxTotalSizestringrequiredMax total memory, e.g. "1GB".
connectors[*].memory.emitMetrics*boolnilEmit Ristretto cost/set-failure metrics when true.
connectors[*].redis.addrstring""Redis host:port. Mutually exclusive with uri.
connectors[*].redis.uristring""Full Redis URI. Preferred over addr.
connectors[*].redis.passwordstring""Redacted in JSON/YAML marshal.
connectors[*].redis.dbint0Redis DB index.
connectors[*].redis.connPoolSizeint0 (driver default)Connection pool size.
connectors[*].redis.initTimeoutDurationdriver defaultTimeout for initial connection.
connectors[*].redis.getTimeoutDurationdriver defaultPer-GET timeout.
connectors[*].redis.setTimeoutDurationdriver defaultPer-SET timeout.
connectors[*].redis.lockRetryIntervalDurationdriver defaultDistributed lock retry interval.
connectors[*].redis.tls*TLSConfignilOptional TLS for Redis.
connectors[*].dynamodb.tablestringrequiredDynamoDB table name.
connectors[*].dynamodb.regionstringrequiredAWS region.
connectors[*].dynamodb.partitionKeyNamestringrequiredPartition key attribute name.
connectors[*].dynamodb.rangeKeyNamestringrequiredRange key attribute name.
connectors[*].dynamodb.reverseIndexNamestringrequiredGSI name for reverse index (wildcard block lookups).
connectors[*].dynamodb.ttlAttributeNamestringrequiredDynamoDB TTL attribute name.
connectors[*].dynamodb.initTimeoutDurationdriver defaultInit timeout.
connectors[*].dynamodb.getTimeoutDurationdriver defaultPer-GET timeout.
connectors[*].dynamodb.setTimeoutDurationdriver defaultPer-SET timeout.
connectors[*].dynamodb.maxRetriesint0AWS SDK max retries.
connectors[*].dynamodb.statePollIntervalDurationdriver defaultState poller interval.
connectors[*].dynamodb.lockRetryIntervalDurationdriver defaultDistributed lock retry interval.
connectors[*].postgresql.connectionUristringrequiredPostgreSQL connection URI. Redacted in marshal.
connectors[*].postgresql.tablestring"erpc_json_rpc_cache" (cache scope)Auto-set to "erpc_json_rpc_cache" when scope=cache and value is blank. Source: common/defaults.go:L1027
connectors[*].postgresql.minConnsint324 (cache scope)Min pool connections. Source: common/defaults.go:L1034-1040
connectors[*].postgresql.maxConnsint3232 (cache scope)Max pool connections. Source: common/defaults.go:L1041-1047
connectors[*].grpc.bootstrapstring""HTTP URL to fetch gRPC server list.
connectors[*].grpc.servers[]stringnilExplicit gRPC server URLs. When both bootstrap and servers are provided, explicit list takes precedence and bootstrap is also resolved and appended. Source: data/grpc.go:L85-95
connectors[*].grpc.headersmap[string]stringnilHeaders sent with every gRPC request.
connectors[*].grpc.getTimeoutDuration100msPer-call timeout. Applied when the existing context deadline is absent or farther in the future than getTimeout. Footgun: older docs incorrectly stated 0 (no override); the correct runtime default is 100ms. Source: common/defaults.go:L927-929
connectors[*].failsafeForGets[]*FailsafeConfignilPer-connector failsafe for Get operations. Consensus and hedge-quantile are not supported at this scope. Source: data/cache_executor.go:L31-38
connectors[*].failsafeForSets[]*FailsafeConfignilPer-connector failsafe for Set operations. Same restrictions as Gets.

evmJsonRpcCache.policies[*]

FieldTypeDefaultBehavior / footguns
policies[*].connectorstringrequiredID matching a connectors[*].id.
policies[*].networkstring"*"Wildcard/glob network filter against networkId. Source: common/defaults.go:L608-610
policies[*].methodstring"*"Wildcard/glob method filter. Source: common/defaults.go:L605-607
policies[*].params[]interface{}nilPositional parameter pattern; nil matches any params. Each element: string (wildcard-matched), object (recursive key match), array (index match), or nil (match any/absent). Extra params beyond pattern length are ignored.
policies[*].finalityDataFinalityStatefinalized (zero value of iota)One of finalized, unfinalized, realtime, unknown. Footgun: omitting this field is byte-identical to finality: finalized. Realtime methods will silently miss unless a separate finality: realtime policy exists. Source: common/data.go:L9-27
policies[*].emptyCacheEmptyBehaviorignore (iota=0)One of ignore, allow, only.
policies[*].appliesToCachePolicyAppliesTo"both"One of "both", "get", "set". Restricts whether the policy is evaluated for reads, writes, or both. Source: common/defaults.go:L611-613
policies[*].minItemSize*stringnilByteSize string ("512B", "1KB", "2MB"). Measured as pre-compression length of the JSON-RPC result field bytes. Responses smaller than this are silently skipped (no error, increments erpc_cache_set_skipped_total). Source: architecture/evm/json_rpc_cache.go:L1069-1072
policies[*].maxItemSize*stringnilByteSize string. Same format and measurement as minItemSize. Responses whose result byte length exceeds this are silently skipped. Source: data/cache_policy.go:L152-160
policies[*].ttlDuration0 (unlimited)Time-to-live stored with each key. When set on a realtime policy, also used as the freshness window in the age gate.

Worked examples

All patterns below are distilled from real production fleets; comments explain the non-obvious choices.

1. Two-tier memory connectors: fast unfinalized + large finalized pool. Production fleets separate recent reorgable data from immutable historical data because their access patterns and eviction pressures differ. Unfinalized blocks churn quickly; finalized blocks are read far more and rarely evicted:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: memory-cache-unfinalized        driver: memory        memory:          # High item count for fast-cycling recent blocks and tx receipts          maxItems: 2000000          # Cap at 8GB: recent data is small-ish JSON, 2M items * ~4KB avg          maxTotalSize: 8GB      - id: memory-cache-finalized        driver: memory        memory:          # Lower count: finalized items are large (full blocks, log ranges)          maxItems: 500000          maxTotalSize: 4GB    policies:      - connector: memory-cache-unfinalized        network: "*"        # Only methods this fleet actually indexes — wildcard eth_get* wastes RTTs        # on guaranteed misses for eth_getBalance, eth_getCode, eth_getProof, etc.        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: unfinalized        # empty: allow — a null receipt means "not indexed yet"; still worth        # caching so rapid polling doesn't hammer upstreams for the same miss        empty: allow        ttl: 0      - connector: memory-cache-unfinalized        network: "*"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: unknown        empty: allow        ttl: 0      - connector: memory-cache-finalized        network: "*"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: finalized        empty: allow        # 0 = no expiry: finalized data is reorg-immune and never goes stale        ttl: 0

2. gRPC read-through connector with static-delay hedge and per-call timeout. When the primary cache backend is a remote gRPC server (BDS read-through), add a short static hedge so a slow node doesn't hold up the request — note that quantile mode is rejected at connector scope, so the delay must be static. The 400ms timeout keeps the cache miss path fast enough that going straight to upstreams is always cheaper than waiting on a struggling connector:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: grpc-cache        driver: grpc        grpc:          # getTimeout: applied when parent context has no deadline or a farther deadline          # 400ms: if the cache read takes longer, skip it and go to upstreams          getTimeout: 400ms          servers:            - https://rpc.example.com:50051          headers:            Authorization: "Bearer ${CACHE_API_TOKEN}"        failsafeForGets:          - matchMethod: "*"            timeout:              # Hard ceiling on a single get call; hedge fires before this              duration: 400ms            hedge:              # Static: quantile mode is not supported at connector scope              delay: 100ms              maxCount: 1            retry:              maxAttempts: 2              # 0: retry immediately — no point waiting for a slow cache node              delay: "0"    policies:      - connector: grpc-cache        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: unfinalized        empty: allow        # get-only: gRPC connector is read-only, no Set support        appliesTo: get        ttl: 0      - connector: grpc-cache        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: unknown        empty: allow        appliesTo: get        ttl: 0      - connector: grpc-cache        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: finalized        empty: allow        appliesTo: get        ttl: 0

3. Chain-specific gRPC reader in front of a wildcard fallback pool. Production fleets often have a high-fidelity per-chain reader colocated in the same cluster alongside a broader pooled connector that covers every chain. The chain-specific connector wins the connector dedup for its network; the wildcard connector catches everything else. Per-chain reader policies come first so their network: "evm:1" entries win before the wildcard network: "*" entry:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: eth-reader        driver: grpc        grpc:          getTimeout: 400ms          servers:            - grpc://eth-reader-lb.cache.svc.cluster.local:50051        failsafeForGets:          - matchMethod: "*"            retry: { maxAttempts: 2, delay: "0" }            timeout: { duration: 400ms }            hedge: { delay: 100ms, maxCount: 1 }      - id: edge-cache-pool        driver: grpc        grpc:          getTimeout: 400ms          servers:            - https://cache-base.rpc.example.com:50051            - https://cache-arbitrum.rpc.example.com:50051        failsafeForGets:          - matchMethod: "*"            retry: { maxAttempts: 2, delay: "0" }            timeout: { duration: 400ms }            hedge: { delay: 100ms, maxCount: 1 }      - id: memory-cache-finalized        driver: memory        memory: { maxItems: 500000, maxTotalSize: 4GB }    policies:      # Chain-specific per-chain reader policies come first — connector dedup means these      # win the evm:1 slot; the wildcard pool never sees evm:1 requests      - connector: eth-reader        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: unfinalized        empty: allow        appliesTo: get        ttl: 0      - connector: eth-reader        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: unknown        empty: allow        appliesTo: get        ttl: 0      - connector: eth-reader        network: "evm:1"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt"        finality: finalized        empty: allow        appliesTo: get        ttl: 0      # Wildcard pool covers all other chains      - connector: edge-cache-pool        network: "*"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: unfinalized        empty: allow        appliesTo: get        ttl: 0      - connector: edge-cache-pool        network: "*"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: unknown        empty: allow        appliesTo: get        ttl: 0      - connector: edge-cache-pool        network: "*"        method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*"        finality: finalized        empty: allow        appliesTo: get        ttl: 0      # In-memory finalized cache for all chains — written by upstreams, read locally      - connector: memory-cache-finalized        network: "*"        method: "*"        finality: finalized        ttl: 0

4. Intentionally no realtime caching. Some fleets deliberately skip the finality: realtime bucket so eth_blockNumber, eth_getBlockByNumber("latest"), and eth_gasPrice always go to fresh upstreams. This is the right choice when your cache connector doesn't implement CacheHeadReporter (all drivers except gRPC) — without a block timestamp the realtime age gate fails open and you'd serve stale chain-tip data. The three-bucket shape below makes the intent explicit and avoids the silent miss:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: mem        driver: memory        memory: { maxItems: 500000, maxTotalSize: 4GB }    policies:      # Deliberately no finality: realtime policy — eth_blockNumber and "latest"      # calls always bypass cache and go to upstreams for a fresh head      - connector: mem        network: "*"        method: "*"        finality: finalized        ttl: 0      - connector: mem        network: "*"        method: "*"        finality: unfinalized        # ~2x Ethereum block time; adjust to 2x your slowest chain's block time        ttl: 24s      - connector: mem        network: "*"        method: "*"        finality: unknown        ttl: 0

5. Shadow-write debugging mode. Write cache entries for a new connector without risking stale reads — appliesTo: set means reads always go to upstreams while you verify the connector is populating correctly. Flip to appliesTo: both once you've confirmed hit rates in erpc_cache_set_success_total:

database.evmJsonRpcCache
erpc.yaml
database:  evmJsonRpcCache:    connectors:      - id: new-pg        driver: postgresql        postgresql:          connectionUri: "postgres://${DB_USER}:${DB_PASS}@db-new:5432/erpc"          # table auto-set to "erpc_json_rpc_cache" at cache scope when blank    policies:      - connector: new-pg        finality: finalized        # set only: reads still hit upstreams; verify connector fills correctly        appliesTo: set        ttl: 0

Request/response behavior

  • Cache key structure. Partition key: {networkId}:{blockRef}. Range key: {method}:{sha256(params)}. When blockRef = "*" (tag-based or multi-block range), the connector is queried via the reverse index (ConnectorReverseIndex = "idx_reverse"). When blockRef = "", the lookup is bypassed entirely.
  • X-ERPC-Skip-Cache-Read header / skip-cache-read query param. Value "true" skips all connectors. A glob pattern (e.g. "mem*") skips matching connectors only. Passing an empty connector ID with a pattern always returns false — patterns are only evaluated per-connector.
  • Responses with a JSON-RPC error field are never cached. Neither are empty results for blocks beyond the network's confidence head, regardless of empty policy setting.
  • Set has a hard 5-second per-connector write timeout, independent of any configured failsafe. If both are configured, the shorter one fires first.
  • Realtime age gate uses block timestamp, not wall-clock cache age. The erpc_cache_get_age_guard_reject_total metric fires when age exceeds TTL; its label is method not category — unique among cache metrics.

Best practices

  • Always define four policies (one per finality bucket) when you want to cache all traffic. A single catch-all policy with no explicit finality: silently caches only finalized data. eth_blockNumber, eth_gasPrice, eth_call with "latest", and eth_getBalance with "latest" are all realtime and will miss unless a finality: realtime policy exists.
  • Keep realtime TTL between 1 and 3 s. Lower than 1 s means the freshness gate will almost always reject on slower connectors; higher than 3 s risks serving chain-tip data that is multiple blocks stale.
  • Set finality: unfinalized TTL at 2× block time. For Ethereum mainnet (~12 s) use 24 s. This gives enough room for the block to finalize without serving stale data for too long.
  • Place the fastest connector first in the policy list when sharing a finality bucket. The Get fan-out races all connectors in parallel, but list order determines which connector wins ties and how quickly cancelation propagates. Memory connectors should come before Redis or PostgreSQL.
  • Do not set compression.enabled: false unless you have profiled actual CPU overhead. zstd at "fastest" level saves 50–80% storage on typical ABI-encoded JSON-RPC results. The benefit almost always outweighs the CPU cost, and the memory connector would double-compress without it (both layers detect magic bytes and short-circuit, but the extra pass is wasted work only when compression is enabled on both).
  • Use minItemSize to avoid caching tiny responses. Responses under 200 B are often null, short errors, or simple scalars. Caching them creates key-count pressure on Ristretto with negligible savings.
  • Monitor erpc_cache_get_age_guard_reject_total vs erpc_cache_get_success_hit_total for realtime policies. A high reject ratio means your realtime TTL is too tight relative to connector polling latency or your GrpcConnector head poll interval (60 s). Raise the TTL or switch to a connector with an embedded block timestamp in responses.

Edge cases & gotchas

  1. finality: finalized is the zero value — intentionally the safest default. The Go comment at common/data.go:L10-L15 (opens in a new tab) explicitly states finalized gets 0 so that an unspecified finality defaults to the safest sane cache default. Omitting finality means only finalized requests match; realtime, unfinalized, and unknown requests bypass this policy and always hit upstreams.

  2. Realtime finality must be stated explicitly — omission silently drops all realtime traffic. Common realtime methods that silently miss without finality: realtime: eth_blockNumber, eth_gasPrice, eth_call with "latest", eth_getBalance with "latest". This is the most common misconfiguration.

  3. findGetPolicies deduplicates by connector; findSetPolicies does not. A connector can receive multiple writes for the same request if multiple policies reference it. Source: architecture/evm/json_rpc_cache.go:L926-L978 (opens in a new tab).

  4. Emptyish hit under empty=ignore is detected in the fan-out goroutine, not post-fan-out. Without this, the emptyish result would win the race and cancel peers, losing a chance for a peer with empty=allow and non-empty data to win. Source: architecture/evm/json_rpc_cache.go:L343-L351 (opens in a new tab).

  5. The realtime age gate checks block timestamp, not cache-entry age. A response cached 5 minutes ago is accepted if its block was produced 3 seconds ago. A response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s. Source: architecture/evm/json_rpc_cache.go:L841-L920 (opens in a new tab).

  6. eth_blockNumber, eth_gasPrice, and eth_getLogs carry no block timestamp in their response. The realtime age gate falls back to the connector's CacheLatestBlockTimestamp, which is only implemented by GrpcConnector (polls every 60 seconds). For all other connectors the gate fails open (accepts the cached result). Source: architecture/evm/json_rpc_cache.go:L879-L889 (opens in a new tab).

  7. Compression threshold has two definitions. The inline code default in NewEvmJsonRpcCache is 512 bytes, but SetDefaults sets it to 1024 bytes. SetDefaults always runs at startup, so the effective default is 1024. The 512 value is only reachable when the cache object is constructed directly without SetDefaults (i.e., in unit tests). Source: common/defaults.go:L597-L599 (opens in a new tab).

  8. Set has a hard 5-second timeout per connector in addition to any failsafe timeout. If both are configured, the shorter one fires first. The 5-second fallback protects connectors with no failsafe configured. Source: architecture/evm/json_rpc_cache.go:L768 (opens in a new tab).

  9. minItemSize and maxItemSize measure pre-compression result length, not stored blob size. A 4 KB result that compresses to 400 bytes still fails a maxItemSize: 2KB check. The measured value is JsonRpcResponse.ResultLength() — raw JSON result bytes, not the full JSON-RPC envelope. Source: architecture/evm/json_rpc_cache.go:L1067-L1072 (opens in a new tab).

  10. Memory connector has independent internal zstd compression. Combined with the EvmJsonRpcCache layer's own compression pass, values stored in the memory connector may be double-compressed. Both layers detect already-compressed data by magic bytes and short-circuit. Source: architecture/evm/json_rpc_cache.go:L1150-L1157 (opens in a new tab).

  11. SkipCacheRead accepts both boolean and string values. Both normalized to string on parse: true"true", false"false". The directive is accepted from config, the X-ERPC-Skip-Cache-Read HTTP header, and the skip-cache-read query parameter. Passing a glob pattern with an empty connectorId always returns false. Source: common/request.go:L895-914.

  12. Policy ordering matters for Get when finality = unknown. All matching policies race in parallel; the fastest connector with data wins. Place the fastest connector first so it most likely wins. For Set, all matching policies receive the write regardless of order.

  13. gRPC connector is read-only. It does not support Set. Supported methods: eth_getBlockByNumber, eth_getBlockByHash, eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt, eth_getBlockReceipts, eth_chainId, eth_blockNumber. eth_blockNumber is derived by fetching the latest block internally. Source: data/grpc.go:L270-278.

  14. appliesTo: "set" without a paired "get" policy writes succeed but reads always miss — useful for shadow-write debugging but rarely the right production shape.

  15. reqRefs with multiple conflicting block references collapse to "*". eth_getLogs with different fromBlock/toBlock values routes to the reverse index and the partition key becomes *. Users must cache only finalized data for range-query methods to avoid serving stale reorged data. Source: architecture/evm/block_ref.go:L296-L300 (opens in a new tab).

  16. ErrRecordNotFound and ErrEndpointMissingData are semantic misses, not errors. They must not increment erpc_cache_get_error_total. A regression where this happened caused 36k+ spurious errors per 15 minutes. Source: erpc/evm_json_rpc_cache_fanout_test.go (opens in a new tab).

  17. database.evmJsonRpcCache: nil completely disables the cache subsystem. The EvmJsonRpcCache object is never created, saving memory and eliminating block-ref extraction overhead on every request. Source: common/defaults.go:L831-836.

  18. Compression is auto-enabled — you cannot disable it by omitting the block. CacheConfig.SetDefaults always creates &CompressionConfig{} and calls SetDefaults() on it (sets enabled=true). The only escape is explicitly setting compression.enabled: false. Source: common/defaults.go:L482-488.

  19. Cancellation guard uses fanCtx.Err(), not errors.Is(err, context.Canceled). An inner failsafe wrapper can produce an opaque typed error that errors.Is cannot unwind. The fan-out loop trusts fanCtx.Err() as the authoritative cancellation signal so a losing peer's wrapped error never inflates erpc_cache_get_error_total. Source: erpc/evm_json_rpc_cache_fanout_test.go (opens in a new tab).

  20. Empty results for future (not-yet-produced) blocks are never cached, regardless of empty policy setting. emptyResultBeyondConfidence compares the requested block number against the network's latest (or finalized) head. If the block hasn't been produced yet, the empty result is silently discarded — preventing the cache from poisoning responses for blocks that simply don't exist yet. Source: architecture/evm/json_rpc_cache.go:L1079-L1082 (opens in a new tab), architecture/evm/common.go:L55-L89 (opens in a new tab).

  21. gRPC connector's block-head timestamp is set to zero when timestamp parse fails. This is intentional: storing a stale timestamp from a previous poll would make CacheLatestBlockTimestamp report a dangerously stale age. Storing 0 makes the realtime guard fail-open (accept the cached result) rather than reject based on wrong age data. Source: data/grpc.go:L350-L356 (opens in a new tab).

  22. appliesTo: "get" enables independent write/read policies with different TTLs or finality. Setting appliesTo: "get" on a policy means it will never be written to — a separate write-only policy (possibly with a different finality bucket or size limit) can populate the connector while the read-only policy controls what is served. The default "both" means the same policy governs both directions.

Observability

MetricTypeLabelsWhen it fires
erpc_cache_get_skipped_totalcounterproject, network, categoryNo matching policy found for request
erpc_cache_get_success_hit_totalcounterproject, network, category, connector, policy, ttlCache hit returned to caller
erpc_cache_get_success_miss_totalcounterproject, network, category, connector, policy, ttlAll connectors confirmed miss
erpc_cache_get_error_totalcounterproject, network, category, connector, policy, ttl, errorConnector transport/non-semantic error
erpc_cache_get_age_guard_reject_totalcounterproject, network, method, connector, policy, ttlBlock timestamp age exceeded policy TTL; only realtime requests with non-zero TTL. Label is method not category — unique among cache metrics
erpc_cache_get_success_hit_duration_secondshistogramproject, network, category, connector, policy, ttlLatency of a cache hit
erpc_cache_get_success_miss_duration_secondshistogramproject, network, category, connector, policy, ttlLatency of a cache miss
erpc_cache_get_error_duration_secondshistogramproject, network, category, connector, policy, ttl, errorLatency when a connector returns a transport error
erpc_cache_set_success_totalcounterproject, network, category, connector, policy, ttlConnector Set succeeded
erpc_cache_set_error_totalcounterproject, network, category, connector, policy, ttl, errorConnector Set failed
erpc_cache_set_skipped_totalcounterproject, network, category, connector, policy, ttlshouldCacheResponse returned false (size limit, error field, empty-ignore, future block)
erpc_cache_set_original_bytes_totalcounterproject, network, category, connector, policy, ttlCumulative uncompressed bytes before any compression attempt; emitted for every successful set
erpc_cache_set_compressed_bytes_totalcounterproject, network, category, connector, policy, ttlCumulative bytes after zstd compression. Only emitted when compression was actually applied (enabled, value ≥ threshold, compressed < original). A zero value does NOT indicate a bug — it means compression was not applied in that window
erpc_cache_set_success_duration_secondshistogramproject, network, category, connector, policy, ttlLatency of a successful set
erpc_cache_set_error_duration_secondshistogramproject, network, category, connector, policy, ttl, errorLatency when a connector Set returns an error
erpc_ristretto_cache_current_costgaugeconnectorMemory connector current cost in bytes
erpc_ristretto_cache_sets_failed_totalcounterconnectorRistretto dropped or rejected a set
erpc_cache_connector_earliest_block_numbergaugeconnector, networkgRPC connector earliest known block number (refreshed every 60 s)
erpc_cache_connector_latest_block_numbergaugeconnector, networkgRPC connector latest block number (refreshed every 60 s)
erpc_cache_connector_finalized_block_numbergaugeconnector, networkgRPC connector finalized block number (refreshed every 60 s)
erpc_cache_connector_earliest_block_timestamp_secondsgaugeconnector, networkgRPC connector earliest block unix timestamp (refreshed every 60 s)
erpc_cache_connector_latest_block_timestamp_secondsgaugeconnector, networkgRPC connector latest block unix timestamp (refreshed every 60 s)
erpc_cache_connector_finalized_block_timestamp_secondsgaugeconnector, networkgRPC connector finalized block unix timestamp (refreshed every 60 s)

OTel trace spans:

SpanSource
Cache.Getarchitecture/evm/json_rpc_cache.go:L153
Cache.FindGetPoliciesarchitecture/evm/json_rpc_cache.go:L173
Cache.GetForPolicyarchitecture/evm/json_rpc_cache.go:L241
Cache.Setarchitecture/evm/json_rpc_cache.go:L572
Evm.ExtractBlockReferenceFromRequestarchitecture/evm/block_ref.go:L18
Evm.ExtractBlockTimestampFromResponsearchitecture/evm/block_ref.go:L190
Request.GenerateCacheHashcommon/json_rpc.go:L1387

Span attributes on Cache.Get: network.id, request.method, request.finality, cache.policies_matched, cache.hit (bool), cache.miss_reason, cache.miss_connector_id, cache.miss_policy.

Span attributes on Cache.GetForPolicy: cache.policy_summary, cache.connector_id, cache.method, cache.get_outcome (found/miss/ttl_rejected/empty_ignored/error/cancelled), cache.block_ref, cache.group_key, cache.request_key.

Debug log messages

These DEBUG-level log messages appear in eRPC output and are useful for tracing cache behavior:

MessageCondition
"will not cache the response because we cannot resolve a block reference"blockRef is empty on Set — method not in the cacheable set
"skip caching because response contains an error"shouldCacheResponse skipped due to JSON-RPC error field
"skip caching because response size does not match policy limits"result length outside minItemSize/maxItemSize
"skip caching empty result for a not-yet-produced (future) block"emptyResultBeyondConfidence — block number is ahead of network head
"rejecting cached result because block age exceeds policy TTL"age guard rejection in shouldAcceptCachedResult
"cached result rejected due to age exceeding TTL"same, logged at the Get fan-out level
"cache connector errored during GET"connector transport error (not a semantic miss)
"skipping cache connector due to skip-cache-read directive pattern"SkipCacheRead directive matched a connector ID glob
"returning cached response"hit served (Trace level also logs raw result)
"compressed cache value"zstd compression applied (includes original/compressed/savings)
"decompressed cache value"zstd decompression applied on read

Source code entry points

Related pages

  • Storage connectors — configure the memory, Redis, DynamoDB, PostgreSQL, and gRPC backends referenced by policies.
  • Rate limiters — limit how many upstream calls the cache miss path can trigger.
  • Failsafe — retry/timeout/circuit-breaker applied per-connector via failsafeForGets/failsafeForSets.
  • Reduce RPC costs — the primary use case this feature serves.
  • EVM method reference — per-method finality classification and built-in reqRefs/respRefs defaults.