Skip to content

Cache invalidation

Replace ttls and expiry policies with realtime sync and automated invalidation.

  • Simplifies your stack
  • No more stale data

Realtime caching with automatic invalidation

Caches are seperate local copies of data, maintained close to application code. They speed up data access, reducing latency and increasing scalability.

The challenge with caching is invalidation, i.e.: keeping the cache up-to-date. This is famously one of the hardest problems in computer science.

Electric solves cache invalidation for you by automatically keeping data in sync.

The problem with stale data

A lot of systems today use ad-hoc mechanisms to maintain caches and keep them up-to-date. This leads to engineering complexity, stale data and bad user experience.

This applies both to the data plumbing and the algorithms used to expire data.

Data plumbing

Say you're maintaining a cache of recently updated projects. What happens when one of those projects is renamed? You need to update the cache. So you need a mechanism for reliably propagating updates from your main data source to the cache.

Diagramme illustrating the need for a cache invalidation mechanism

This means you need durability, at-least-once delivery and to be able to recover from downtime. It's easy to get sucked into engineering complexity and it's easy to make mistakes, so a cache either gets stuck with stale data or wiped too often.

Stale data

It's hard to know when a cache entry should be invalidated. Often, systems use ad-hoc expiry dates and "time to live" (or "ttls").

This leads to stale data, which can lead to confused users, integrity violations and having to write code to put safeguards around data you can't trust.

Solved by Electric

Electric solves data plumbing with realtime sync and solves stale data with automated cache invalidation.

Realtime sync

Electric syncs data into caches in realtime. It's fast and reliable, handles durability/delivery and reconnecting after downtime. You just declare the Shape of the data you want in the cache and Electric keeps it in sync.

Diagramme illustrating Electric cache invalidation

Automated cache invalidation

Electric automatically manages the data in your local cache for you. When the data changes, the changes are synced to the local cache which is automatically updated.

You don't need to manage cache invalidation seperately or set expiry dates of TTLs on the records in the cache. Electric handles it for you.

Real world example

Let's look at a real world example, syncing data into a Redis cache. You can see the full source code here.

Maintaining a Redis cache

Many applications use Redis as a local cache. With Electric, you can define a Shape and sync it into a Redis hash. The shape comes through as a log of insert, update and delete messages. Apply these to the Redis hash and the cache automatically stays up-to-date:

ts
import { createClient } from 'redis'
import { ShapeStream, Message, isChangeMessage } from '@electric-sql/client'

// Create a Redis client
const REDIS_HOST = `localhost`
const REDIS_PORT = 6379
const client = createClient({
  url: `redis://${REDIS_HOST}:${REDIS_PORT}`,
})

client.connect().then(async () => {
  console.log(`Connected to Redis server`)

  // Clear out old data on the hash.
  client.del(`items`)

  // Lua script for updating hash field. We need to merge in partial updates
  // from the shape log.
  const script = `
      local current = redis.call('HGET', KEYS[1], KEYS[2])
      local parsed = {}
      if current then
        parsed = cjson.decode(current)
      end
      for k, v in pairs(cjson.decode(ARGV[1])) do
        parsed[k] = v
      end
      local updated = cjson.encode(parsed)
      return redis.call('HSET', KEYS[1], KEYS[2], updated)
    `

  // Load the script into Redis and get its SHA1 digest
  const updateKeyScriptSha1 = await client.SCRIPT_LOAD(script)

  const itemsStream = new ShapeStream({
    url: `http://localhost:3000/v1/shape/items`,
  })
  itemsStream.subscribe(async (messages: Message[]) => {
    // Begin a Redis transaction
    //
    // FIXME The Redis docs suggest only sending 10k commands at a time
    // to avoid excess memory usage buffering commands.
    const pipeline = client.multi()

    // Loop through each message and make writes to the Redis hash for action messages
    messages.forEach((message) => {
      if (!isChangeMessage(message)) return
      console.log(`message`, message)
      // Upsert/delete
      switch (message.headers.operation) {
        case `delete`:
          pipeline.hDel(`items`, message.key)
          break

        case `insert`:
          pipeline.hSet(
            `items`,
            String(message.key),
            JSON.stringify(message.value)
          )
          break

        case `update`: {
          pipeline.evalSha(updateKeyScriptSha1, {
            keys: [`items`, String(message.key)],
            arguments: [JSON.stringify(message.value)],
          })
          break
        }
      }
    })

    // Execute all commands as a single transaction
    try {
      await pipeline.exec()
      console.log(`Redis hash updated successfully with latest shape updates`)
    } catch (error) {
      console.error(`Error while updating hash:`, error)
    }
  })
})

Next steps

Get started with Electric to simplify your stack and avoid stale data.