ScopeCache is a lightweight in-memory datastore/cache and write buffer for Caddy: a simple, scope-partitioned, ordered datastore built for ultra-fast dynamic reads. ScopeCache combines key-value style access with ordered per-scope collections. It runs primarily as a Caddy module, but can also run standalone over a Unix socket.
It stores application data in memory and lets Caddy serve selected reads directly from the web server process. This can reduce pressure on both the database and the application layer.
ScopeCache is not a Redis replacement and not an HTTP response cache. It is an application-managed datastore organized by scope: your application decides what data is stored, under which scope, and when it is updated or removed.
Traditional hot-read paths often look like this:
Caddy -> application runtime (Node.js/PHP/etc.) -> Redis/database -> application runtime -> response
ScopeCache allows selected reads to take a shorter path:
Caddy -> ScopeCache memory -> response
When ScopeCache is compiled into Caddy, it runs in the same OS process as the web server. There is no Redis protocol, no cache-service roundtrip, and no separate cache process on the hot read path.
In benchmark tests, this direct in-process path served data about 9× faster than routing the same request through an app-layer and Redis on the same server. The relevant difference is architectural: ScopeCache removes parts of the request path. It does not claim that Redis itself is slow.
A typical request:
GET /tail?scope=thread:123&limit=100
returns the latest 100 items for the scope thread:123.
Direct PHP access through FrankenPHP
FrankenPHP makes it possible to write PHP extensions in Go, so PHP can access ScopeCache directly inside the same process.
In a end-to-end benchmark on an 8-vCPU VPS, a PHP scopecache_get_by_id() call to ScopeCache and back took around 0.3 microseconds on a 50,000-item random-read workload (best case, single hot key, ~0.14 µs). A regular PHP-to-Redis roundtrip on the same kind of host typically lands around 125 microseconds with a persistent Redis connection, while opening a new Redis connection for a single request adds several hundred microseconds.
The difference is architectural: ScopeCache avoids the extra process and protocol roundtrip that Redis requires.
ScopeCache is not tied to PHP. The same in-process Caddy/cache architecture can be useful in many other use cases and with other platforms as well.
ScopeCache is a small in-memory datastore/cache for workloads where the application already knows which views need to be served quickly.
Good examples include:
- latest messages or reactions in a thread
- private inbox views
- unread counters
- notification lists
- view counters
- rate-limit buckets
- pre-rendered HTML fragments for HTMX-driven interfaces
- small materialized JSON, HTML, XML, or text views
- high-frequency events that need to be drained later
The cache is intentionally disposable. Your source of truth lives elsewhere — usually a database, but it can be any external store (a JSON file, in-memory state, an external API). ScopeCache can be wiped, warmed, or rebuilt from it at any time.
ScopeCache is not a general-purpose Redis replacement. Redis and similar systems offer a much broader feature set: richer data types, more commands, clustering, persistence options, replication, mature operational tooling, and a large ecosystem.
ScopeCache is also not a traditional HTTP response cache like Varnish or Souin. It does not cache responses automatically based on incoming URLs or cache-control headers.
Instead, your application publishes prepared data into named scopes, and Caddy can serve that data directly.
application -> ScopeCache scope/id -> Caddy serves it
ScopeCache stores items inside scopes.
A scope is a named partition, similar to a namespace or bucket. Each scope contains an ordered collection of items. Items are addressable only through official top-level fields:
scope— required partition keyid— optional stable application-owned identifierseq— cache-owned sequence number, monotonically increasing per scope, assigned by ScopeCache on every appendts— cache-owned microsecond timestamp, set by ScopeCache on every write; observability only, not searchable and not used for orderingpayload— required JSON value, treated as opaque application data
ScopeCache does not inspect the payload for filtering or querying. Filtering, addressing, and cursoring only operate on official top-level item fields. IDs, when present, are plain strings whose meaning is decided by the application.
ScopeCache deliberately avoids becoming a query engine.
There is no query language, no joins, no arbitrary predicates, no sorting DSL, and no payload inspection beyond validation at write time.
Instead, flexibility comes from creating materialized views ahead of time:
thread:34
user:42:inbox
user:42:unread
tenant:acme:thread:34
If you need “all unread notifications for user 42”, your application stores that view under a scope such as:
user:42:unread
ScopeCache then serves that scope quickly. It does not search through payloads to discover it.
Although filtering is limited, ScopeCache remains flexible — and usable for most real-world use cases — through scope and ID naming. Instead of searching inside payloads, the application encodes access patterns directly into scope names.
Because each scope is ordered by its cache-assigned seq, retrieving the latest 100 unread messages for that user is a native operation:
/tail?scope=user:42:unread&limit=100
ScopeCache combines key-value style access with ordered per-scope collections. Direct lookups by id or seq behave like simple key-value reads, while the built-in sequence order makes operations such as tail, head, and since(seq) natural core primitives.
ScopeCache can serve hot dynamic data directly from Caddy.
This is useful when normal HTTP response caching is inconvenient or too coarse-grained, for example:
- private user-specific data
- frequently changing thread data
- inboxes
- notifications
- counters
- pre-rendered fragments
Your application prepares the data and writes it into ScopeCache. Caddy then serves selected reads without involving the application runtime.
ScopeCache can also buffer high-frequency events such as analytics hits, log lines, or chat messages.
A worker can drain the buffer in batches using endpoints such as /tail and /trim, then process, persist, or forward the data elsewhere.
ScopeCache can serve cached HTML, JSON, XML, or text directly through /get (its default raw mode returns the stored payload bytes, no JSON envelope). To assemble a page from many fragments in one network round-trip, POST /get_many fetches a batch of point-targets and returns a positional array (or, with ?envelope=true, per-target hit/item).
This may look similar to an HTTP response cache, but the model is different. ScopeCache does not decide what to cache from incoming requests. Your application precomputes the content, stores it under a scope and ID, and Caddy serves it directly when requested.
The core HTTP API is intentionally small.
- Read:
get,head,tail - Write:
append,upsert,update - Bulk / load:
warm,rebuild - Cleanup:
delete,trim,drop_scope,flush - Observe:
stats,scopelist,help
The exact endpoint contracts are documented in docs/scopecache-core-rfc.md.
Redis is excellent software. But for narrow hot-read patterns, it can be more infrastructure than the workload requires.
Redis is often used to reduce pressure on the database, but the request still usually passes through the application layer:
web server -> application -> Redis -> application -> response
ScopeCache is built for cases where the cache can safely be disposable, rebuilt from the source of truth, and served directly from the web server process.
For suitable read paths, ScopeCache can reduce pressure on both:
- the database
- the application runtime
That means fewer moving parts, lower per-request overhead, and higher HTTP throughput for the specific paths that fit this model.
The other major motivation for ScopeCache is FrankenPHP.
FrankenPHP shows how powerful a Caddy-based architecture can be when more of the web stack runs together. Its worker mode improves PHP performance by keeping application workers alive in memory, avoiding much of the overhead of traditional per-request PHP execution.
FrankenPHP also makes distribution simpler: a PHP application can be packaged into a single binary that includes Caddy and Mercure for Server-Sent Events. No separate installation and configuration of a web server or PHP runtime is required.
But when Redis is required for optimal performance, that distribution model becomes less self-contained. Redis remains a separate service that must be run, secured, monitored, and maintained. It also cannot be compiled into the same single binary as the PHP application, web server, and Mercure.
FrankenPHP reduces one service boundary by bringing the PHP runtime closer to Caddy. ScopeCache applies a related idea to data: keep frequently accessed data inside the web server process and avoid an additional cache-service roundtrip on the read path.
Because ScopeCache is a Caddy module, it can be compiled into the same custom FrankenPHP/Caddy binary as Caddy, the PHP runtime, and the SSE hub.
One important advantage of FrankenPHP’s design is that PHP extensions can be written in Go. A small Go file with //export_php:function directives can be processed by a generator, making Go functions available in PHP as native function calls.
Because FrankenPHP and Caddy are tightly integrated, Caddy, PHP, and ScopeCache run inside the same OS process: one PID, one address space.
The result is a much cheaper PHP-to-cache path: there is no need for a cURL call and a loopback HTTP request. Reads such as the PHP function scopecache_get_by_id() can run in-process and reach millions of calls per second, while still sharing the same ScopeCache Gateway used by the Caddy module.
// scopecache_append(scope, id, payload)
scopecache_append('users', 'alice', json_encode(['name' => 'Alice', 'age' => 31]));
// {"ok":true,"created":true,"item":{"scope":"users","id":"alice","seq":1,"ts":1715600000123456}}
// scopecache_get_by_id(scope, id)
$json = scopecache_get_by_id('users', 'alice');
// {"name":"Alice","age":31}There is still a PHP-to-Go extension boundary, including type conversion. But the path is much shorter than a typical PHP-to-Redis lookup.
The extension lives in addons/frankenphp-ext/ — it documents the full set of 20 PHP functions, build steps, and the shared-Gateway model.
To fully unlock the potential of FrankenPHP, you need more than an embedded PHP runtime. You also need application data that can live inside the same process as the application code. ScopeCache is built for that role.
ScopeCache’s internal storage model is deliberately simple.
Each scope owns one ordered slice of items, stored in append order. Around that slice, ScopeCache maintains lightweight hashmap indexes for direct lookup by id and seq.
Conceptually, the core shape is:
type Item struct {
Scope string // required partition key
ID string // optional, application-owned identifier
Seq uint64 // cache-assigned, monotonic per scope
Ts int64 // cache-assigned UnixMicro timestamp
Payload []byte // opaque JSON
}
type scopeBuffer struct {
items []*Item // primary storage, in append order
byID map[string]*Item // id -> item
bySeq map[uint64]*Item // seq -> item
mu sync.RWMutex // one lock per scope
lastSeq uint64 // scope metadata: next seq to assign on append
bytes int64 // scope metadata: running total of stored item bytes
createdTS int64 // scope metadata: first-write timestamp (UnixMicro)
lastWriteTS int64 // scope metadata: most recent write touch
lastAccessTS int64 // scope metadata: most recent read touch
readCountTotal uint64 // scope metadata: lifetime read counter
}The slice is the ordered storage. It defines the physical order of the data in memory and makes operations such as head, tail, and cursor-based reads natural.
The maps exist to avoid scanning. A lookup by id or seq is an O(1) hashmap lookup on average, independent of the number of items in the scope.
The slice and both maps hold pointers to the same items, so each item lives in memory once, no matter how many indexes address it.
The scope metadata fields exist for two reasons. lastSeq drives the seq-assignment on the next append, and bytes lets the store-wide byte budget admit-or-reject writes without re-scanning items. The four timestamp/count fields surface on /stats and /scopelist so an operator can see per-scope activity at a glance.
A classical key-value store is conceptually built around:
key -> value
Ordering is not part of that basic model. If you want “the latest 10 items”, you usually build that on top with lists, streams, sorted sets, timestamps, or secondary indexes.
ScopeCache starts from a different shape:
scope -> ordered collection -> indexed items
Each scope is an ordered collection first, with direct lookup indexes around it. That is why operations such as head, tail, and cursor-based reads are native core primitives rather than conventions layered on top of a flat keyspace.
Internally, the top-level store is sharded by scope name. A request may briefly touch a shard-level lock to find the scope buffer. After that, the operation is handled by the scope’s own buffer.
That means unrelated scopes do not block each other during normal per-scope operations.
scope "thread:1" -> own buffer -> own lock
scope "thread:2" -> own buffer -> own lock
scope "user:42" -> own buffer -> own lock
Reads share a per-scope read lock, so multiple reads on the same scope can run concurrently. Writes take the per-scope write lock for the duration of the mutation.
This matches the data model: scopes are not only names, but natural concurrency partitions.
A request such as:
GET /get?scope=X&seq=N
resolves conceptually as:
scope lookup -> bySeq lookup -> item
Both lookup steps are O(1) on average. Ordered reads such as /head and /tail walk the items slice instead.
A single in-process lookup can take tens of nanoseconds. In one benchmark, getBySeq took about 43 ns per lookup on a single CPU core, roughly 23 million lookups per second. Because each scope has its own read/write lock, reads on different scopes scale independently across cores; reads on the same scope share a read-lock and also run concurrently.
These internal numbers refer only to the in-process lookup itself. They do not include Caddy routing, HTTP request parsing, response writing, JSON encoding, or network overhead.
The larger HTTP benchmark numbers in this README measure complete request paths.
ScopeCache exposes a public Go API through *Gateway. This documented boundary makes it easier to build focused addons without reaching into internal types such as *store or *scopeBuffer, with all their implementation details and complexity.
The Gateway provides a stable boundary around the cache. It performs uniform validation, applies defensive cloning where needed, and hides the internal implementation details from addons.
ScopeCache includes one convenience mechanism around the core: a warm-up script hook that runs when the web server starts or reboots after a crash. The script is an external executable that talks to ScopeCache over a per-instance private Unix socket, populates the cache from your source of truth (database, JSON file, etc.), and exits. The public socket only opens after the script returns, so cold-start traffic never sees an empty cache.
Configured via the init_command field on the Caddy module.
Run Caddy with ScopeCache baked in on localhost:8081:
git clone https://github.com/VeloxCoding/scopecache.git
cd scopecache
docker compose up -d --build caddyscope
curl http://localhost:8081/helpThe bundled Caddyfile.caddy-scopecache is already wired for GET and POST:
:8080 {
scopecache {
scope_max_items 100000
max_store_mb 100
max_item_mb 1
}
respond 404
}Explanation:
:8080 { ... }— Caddy listens on port 8080 inside the container; Docker Compose maps that to 8081 on your host.scopecache { ... }— ScopeCache endpoints are mounted at/, soGET /help,GET /tail,POST /append, and other endpoints are available.respond 404— anything ScopeCache does not recognize returns 404.scope_max_items,max_store_mb, andmax_item_mbare capacity limits.
# Write an item.
curl -X POST http://localhost:8081/append \
-H 'Content-Type: application/json' \
-d '{"scope":"demo","payload":{"msg":"hello"}}'
# Read it back.
curl 'http://localhost:8081/tail?scope=demo'The xcaddy build recipe for a custom Caddy binary lives in Dockerfile.caddy-scopecache.
A prebuilt binary is available for convenience. It includes FrankenPHP, ScopeCache, and the ScopeCache PHP extension functions.
It runs on Linux x86_64 with zero host dependencies:
# 1. Download
curl -L -o frankenphp-scopecache \
https://github.com/VeloxCoding/scopecache/releases/download/v0.8.58/frankenphp-scopecache-static-linux-amd64
# 2. Make executable + move onto PATH
chmod +x frankenphp-scopecache
sudo mv frankenphp-scopecache /usr/local/bin/
# 3. Run it
frankenphp-scopecache run --config /path/to/your/CaddyfileThe binary is kept as frankenphp-scopecache rather than plain frankenphp so the descriptive name (custom FrankenPHP build with the scopecache module + PHP extension baked in) survives the install, and so it cannot collide with a separately installed upstream frankenphp.
Benchmark results are hardware-dependent. The numbers below were measured on a stock UpCloud VPS: AMD EPYC 9575F (Zen 5), 8 vCPU, 16 GB RAM, Ubuntu 26.04 — no special tuning, single host running both the bench load and the server under the standard kernel scheduler.
The point of this comparison is the relative gap between request paths, not the absolute throughput. Both numbers will scale roughly linearly with core count on a larger machine.
wrk -t4 -c64 -d3s against a pre-seeded 50,000-item scope, random ?id= lookups, 5-run median. ScopeCache is the v0.8.60 caddyscope binary; both wrk and caddyscope share the same 8 cores under the kernel scheduler.
| Route | Requests/sec | p50 latency |
|---|---|---|
| Caddy → ScopeCache (in-process) | 209,000 | 180 µs |
For reference, a separate prior benchmark on a different host compared this in-process path to a Caddy/FrankenPHP-worker → Redis route under identical load and found roughly a 9× throughput gap. That comparison isn't reproduced on this VPS (no Redis installed), but the architectural reason hasn't changed: the in-process path simply has fewer hops in the request path.
When PHP calls ScopeCache directly through the FrankenPHP extension — bypassing the HTTP stack entirely — the same lookup runs much faster. Single PHP thread, pre-warmed, hrtime(true) around a tight loop.
A random read over 50,000 items is the realistic cache workload — every call picks a different id/seq, so CPU caches and branch predictors stay cold across calls:
| Call path | Ops/sec | Per call |
|---|---|---|
scopecache_get_by_id() (random over 50k) |
3.29 M | 304 ns |
scopecache_get_by_seq() (random over 50k) |
3.51 M | 285 ns |
A hot loop hammering the same key is the best-case floor — the in-process cgo + scopecache cost when everything is L1-resident:
| Call path | Ops/sec | Per call |
|---|---|---|
scopecache_get_by_id() (same id, hot loop) |
7.0 M | 143 ns |
scopecache_get_by_seq() (same seq, hot loop) |
7.1 M | 140 ns |
See addons/frankenphp-ext/ for the cgo path.
This is an architectural comparison. The difference is in the request path: a typical PHP or Node route has to leave the webserver, enter the application runtime, call another service such as Redis or a HTTP/cURL, and then build the response. ScopeCache can remove those extra hops for selected hot reads by running inside the Caddy process, and the FrankenPHP extension removes the local HTTP/cURL hop when PHP needs to call ScopeCache directly.
ScopeCache is pre-1.0.
The core HTTP and Go API surfaces may still change between minor versions. After v1.0, the core API is intended to become semver-stable.
go build -o scopecache ./cmd/scopecache
go test ./...Module path:
github.com/VeloxCoding/scopecache
ScopeCache currently uses only the Go standard library.
The full design, endpoint contracts, and architectural rationale live in docs/scopecache-core-rfc.md.
Apache License, Version 2.0. See LICENSE.
Copyright 2026 VeloxCoding.