Philipp Lehmann @PhilippTheServer

Tracking Moving Objects with Redis Sorted Sets and an Atomic Lua Tick

Answering 'where is everything now' and 'what just left' without a database.

The problem

A line of physical or logical positions — a conveyor, a pipeline stage, a queue with positional slots — holds several objects moving at the same rate at once. Two questions need reliable answers at any moment: where is everything right now, and which objects have just moved past the end and need to be handed off to whatever comes next.

The naive approach is a table keyed by object ID with a position column, updated by scanning every row and incrementing it, then a separate query to find and delete the ones past the threshold. Done as two operations, this has a gap: between “read all positions”, “write new positions”, and “delete the ones that exited”, another reader can observe a half-advanced state, and nothing stops two advance operations from racing each other and double-advancing an object.

Reaching for a full database and a transaction to close that gap is not wrong, but it is more machinery than the problem needs. A Redis sorted set already stores exactly this shape of data — a set of members each with a numeric score — and Redis’s scripting model means the entire “advance everything, then remove whatever exited” step can be a single atomic operation with no separate transaction wrapper required.

Working through it

Sorted sets already model “position” directly

A sorted set entry is a member (the object’s ID) and a score (a number Redis keeps sorted on). Using score as position, “where is everything” is ZRANGE key 0 -1 WITHSCORES, and “what is between position A and B” — a specific zone on the line — is ZRANGEBYSCORE key A B WITHSCORES. Both are native sorted-set operations; no schema, no index to maintain.

Advancing every object is a write per member, not a single command

Redis has no “add this delta to every score in a sorted set” primitive — ZINCRBY only updates one member at a time. Advancing everything by one delta genuinely needs a loop over the members. Doing that loop from a client means one round trip per object, and — the part that matters here — another client’s ZRANGE or another advance call can interleave with that loop, observing or acting on a set that is only partially advanced.

Redis scripting removes the interleaving, not the loop

Redis executes a Lua script to completion before serving any other command — a script is not a transaction wrapping several client round trips, it is a single opaque server-side operation from every other client’s point of view. The loop over members still exists, it just moves onto the server, where nothing can observe or interleave with the partial state:

-- tick.lua
-- KEYS[1] = sorted set key
-- ARGV[1] = delta to advance (can be negative)
-- ARGV[2] = exit threshold: positions >= this have left the tracked range
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local exit_threshold = tonumber(ARGV[2])

local members = redis.call('ZRANGE', key, 0, -1, 'WITHSCORES')
local exited = {}
for i = 1, #members, 2 do
  local member = members[i]
  local score = tonumber(members[i + 1])
  local new_score = score + delta
  if new_score >= exit_threshold then
    redis.call('ZREM', key, member)
    table.insert(exited, member)
  else
    redis.call('ZADD', key, new_score, member)
  end
end
return exited

This is the whole mechanism: read every member and its current score, decide per-member whether the new position exits the range, and either remove it (returning it to the caller as “just exited”) or write the new score — all inside one atomic execution. A client that runs ZRANGE immediately before or after a tick observes either the fully-old state or the fully-new state, never something in between, and two ticks issued back to back are serialised by Redis, never interleaved.

Choosing what the score actually means

Score does not have to be a raw distance. A timestamp of entry combined with a known constant speed gives “current position” as a derived value without any tick at all — trade a script for a read-time calculation. A raw position updated by explicit ticks, as above, suits a system where the rate of advance is not constant or is driven by an external event (a physical sensor, a batch step) rather than wall-clock time. Pick based on what actually drives movement in the system being modelled; both are legitimate uses of the same sorted set.

The solution

A complete, runnable demonstration against a disposable Redis instance:

docker run -d --name conveyor-demo -p 6379:6379 redis:7.4-alpine
docker cp tick.lua conveyor-demo:/tmp/tick.lua

Save the Lua script above as tick.lua before running the docker cp. Then, from a shell with the container running:

# Three objects enter at positions 0, 10 and 90 on a line that ends at 100.
docker exec conveyor-demo redis-cli ZADD conveyor:positions 0 crate-1 10 crate-2 90 crate-3

echo "--- where is everything now ---"
docker exec conveyor-demo redis-cli ZRANGE conveyor:positions 0 -1 WITHSCORES

echo "--- advance by 15, exit threshold 100 ---"
docker exec conveyor-demo redis-cli --eval /tmp/tick.lua conveyor:positions , 15 100

echo "--- state after the tick ---"
docker exec conveyor-demo redis-cli ZRANGE conveyor:positions 0 -1 WITHSCORES

Expected output:

--- where is everything now ---
crate-1
0
crate-2
10
crate-3
90
--- advance by 15, exit threshold 100 ---
crate-3
--- state after the tick ---
crate-1
15
crate-2
25

crate-3 moved from 90 to a would-be 105, crossed the 100 threshold, and the script returned it as exited while removing it from the set in the same operation — the caller gets the exact list of what just left, with no separate query needed to find it. Zone queries work the same way against the post-tick state:

docker exec conveyor-demo redis-cli ZADD conveyor:positions 5 crate-4 60 crate-5
docker exec conveyor-demo redis-cli ZRANGEBYSCORE conveyor:positions 0 30 WITHSCORES
crate-4
5
crate-1
15
crate-2
25

That is every object currently between position 0 and 30 on the line, computed with a single command against whatever the last tick left behind.

Conclusion

A sorted set is the right structure whenever “position” is the primary thing being tracked and queried — range queries over position come for free, without a secondary index to keep in step with writes.

Atomicity in Redis does not require a transaction API; a Lua script that runs to completion before anything else is served gives the same guarantee for anything that fits in one script, and is often simpler to reason about than MULTI/EXEC, which only queues commands and does not let one command’s result influence the next within the same transaction.

The pattern generalises past conveyors: anything with a numeric progress value, a threshold that means “done” or “expired”, and a need to atomically advance many of them and collect whichever crossed the line — a TTL-like expiry sweep, a multi-stage pipeline, a leaderboard cutoff — fits the same shape of one sorted set and one tick script.