Redis on the server, some state library in the browser, and a pile of hand-written sync between them. I got tired of writing that layer, so I built a cache engine that compiles to both native and WebAssembly.
Every app I've worked on ends up with the same shape.
There's a cache on the server — Redis, usually. And there's state in the browser — Zustand, Redux, React Query, whatever. Between them sits a layer nobody designed on purpose: fetchedAt timestamps, staleness checks, an invalidation call you have to remember to make after every mutation, and a polling interval you tuned once and never revisited.
That layer is where the bugs live. The cart total that's stale for four seconds. The feature flag that flipped in the dashboard but not in the tab that's already open. The counter that goes backwards because two tabs raced.
The frustrating part is that both halves are caches. They store keys, they expire things, they invalidate. We just built them out of completely different parts and then wrote glue to keep them agreeing.
What if it were literally the same cache?
That's the question behind Recached.
It's a cache server written in Rust that speaks RESP — the Redis wire protocol — on port 6379. Your existing client works unchanged:
import Redis from 'ioredis';
const cache = new Redis('redis://127.0.0.1:6379');
await cache.set('inventory:item:99', '42');
That part is unremarkable. Here's the part that isn't.
The evaluation engine — the thing that actually stores keys, applies TTLs, and evaluates commands — is a single Rust crate with no networking and no file I/O. It compiles to native for the server and to wasm32 for the browser. Same source, same semantics, both sides.
A WebSocket sync layer keeps them in step. So the browser doesn't call the cache. The browser is a cache, holding a live replica.
import { createCache } from 'recached-edge';
const cache = await createCache({
persistence: true, // survives refresh via IndexedDB
connect: { url: 'ws://127.0.0.1:6380' }, // syncs with the server
});
cache.get('inventory:item:99'); // "42" — from local WASM memory
That read doesn't touch the network. It's a lookup in a hash map that happens to be inside your browser tab.
What that changes
Reads stop being async problems. No loading state for data you already have. No stale-while-revalidate dance. The value is in memory or it isn't.
Pushes replace polling. A server-side write fans out over the WebSocket and lands in every connected browser's local copy. In React:
const cart = useKeys('cart:item:*'); // current state + live updates
No /api/cart endpoint. No interval. No socket handler you wrote by hand.
Offline stops being a special case. Writes made while disconnected apply locally and queue in an IndexedDB-backed outbox. On reconnect the client re-authenticates, re-subscribes, and replays. Because operations replay rather than final values, merges follow the data type — two clients incrementing the same counter both land, instead of one clobbering the other.
Tabs agree for free. Cross-tab sync goes over BroadcastChannel, no server round-trip.
The honest part
I'd rather you evaluate this accurately than be surprised later.
The server is in good shape for cache workloads: snapshots and AOF persistence, replication with automatic single-replica failover, TLS, constant-time auth, hardened parsers, and a load/chaos suite in CI.
The browser sync layer is beta. The invariants are specified and tested end to end, but the code is young and hasn't had a third-party security review. Concretely: don't expose the sync port to untrusted multi-tenant traffic until you've read the sync-scopes documentation and understand the model.
On performance — pipelined, it's ahead of Redis on 6 of 7 commands, because it's multi-threaded where Redis executes on one core. Unpipelined it runs at 46–96% of Redis depending on the command; HSET in particular is a known outlier I'm still working on. The full table, including every case where it loses, is published in the docs. The goal was never to beat Redis at being Redis — it's to delete the network hop for client reads, which no server-side cache can do.
Where it isn't the right tool: if nothing but your own backend reads the cache, use Redis. It's more mature, has more data structures, and solves that problem completely. Recached earns its place when a browser, mobile app, or edge worker needs the same data your backend writes.
Try it
docker run -p 6379:6379 -p 6380:6380 ghcr.io/thinkgrid-labs/recached:latest
npm install recached-edge
Docs: recached.dev · Source: github.com/thinkgrid-labs/recached · Apache 2.0
Also in the box: a native JSON type with path updates and RFC 7386 merge, sliding-window rate limiting as a first-class command, live queries, and primary/replica replication.
If you've built that sync layer by hand — and I think most of us have — I'd genuinely like to hear which part broke for you. That's the part I most want to get right.
Top comments (0)