Published on

Keeping Bounded Recent Message History in Redis with LPUSH, LRANGE, and LTRIM

Authors

A live message can reach the correct WebSocket session and still be unavailable when the user opens the conversation again.

That was the gap in a real-time messaging flow I worked on. The broker and WebSocket path handled current delivery, while the interface also needed a small amount of recent context after navigation or reconnection. Keeping every message indefinitely in an in-memory store would have created a different problem, so I used a Redis List as a bounded replay window.

The preserved implementation notes are specific about the data structure and commands: JSON messages were stored with LPUSH, read with LRANGE, and capped with LTRIM. They do not preserve a measured latency comparison, the deployed history limit, or evidence that a TTL policy was enabled. I will not turn those missing details into performance claims. This article focuses on the invariants the design can support and the boundaries it cannot.

The Requirement Was a Replay Window, Not a Transcript

The system already had two forms of short-lived state:

  • the live WebSocket connection knew where to send the next message;
  • browser state could render messages received during the current visit.

Neither was a shared server-side replay source. A reconnect, refresh, or different application instance could leave the interface without the recent context the user expected.

The narrow requirement preserved in the notes was deliberately different from durable chat history:

  1. isolate messages by conversation;
  2. restore only the most recent bounded set;
  3. distinguish who sent each message;
  4. make an absent or expired conversation safe to read;
  5. accept that this cache may be lost or evicted.

That last point is the architectural boundary. Redis was useful here because losing the replay window was recoverable. If the messages were required for audit, dispute resolution, full-history search, or guaranteed recovery, a bounded cache could not be their only copy.

The same separation applies to transport. User-specific STOMP destinations decide who should receive live traffic, while a replay cache decides what a reconnecting client can restore. The user-specific destination design and the multi-server RabbitMQ routing failure cover those delivery problems independently.

A Redis List Matched the Access Pattern

The access pattern was append one message, keep the newest few, and fetch that small window. A List maps directly to those operations:

  • LPUSH inserts a value at the head;
  • LRANGE 0 n reads from the head;
  • LTRIM 0 n removes everything outside the retained range.

Redis documents LPUSH as a prepend operation and shows that the latest pushed element becomes index zero. Its LTRIM documentation presents LPUSH followed by LTRIM as the standard way to maintain a capped list. See the official LPUSH, LRANGE, and LTRIM references.

A safe reconstruction uses one key per conversation. The exact private key format is not preserved; a generalized shape is:

chat:recent:<opaque-conversation-id>

The identifier must be an opaque application ID, not an email address, username, organization name, or other personal data. Authorization still belongs in the application. Knowing or guessing a Redis key must never grant access to a conversation.

The preserved design stored each message as JSON so the reader could distinguish the sender. This illustrative public envelope adds fields that make replay and schema evolution easier; it is not a copy of a private payload:

{
  "v": 1,
  "messageId": "msg-01",
  "sender": "participant",
  "sentAt": "2026-09-07T07:30:00Z",
  "body": "Example text"
}

messageId gives the client a stable deduplication handle. v makes incompatible payload changes detectable. sentAt is useful evidence, but it should not be treated as a perfect ordering source when clocks or concurrent producers can differ.

The Index Direction Is Easy to Get Wrong

With LPUSH, index zero is the newest message. Redis also defines the LRANGE stop index as inclusive. Therefore, an illustrative window of 50 messages uses indexes 0 through 49:

LPUSH chat:recent:conversation-7f3 '{"v":1,"messageId":"msg-03","sender":"participant","sentAt":"2026-09-07T07:32:00Z","body":"Example text"}'
LTRIM chat:recent:conversation-7f3 0 49
LRANGE chat:recent:conversation-7f3 0 49

The number 50 is only an example, not a claim about the deployed configuration. The real limit should come from the product's reconnect requirement and a memory budget.

LRANGE returns the stored order:

[newest, ..., oldest]

Most conversation interfaces render:

[oldest, ..., newest]

A read adapter therefore needs to reverse the small result after decoding it. Reversing at this boundary is simpler than making every caller remember Redis's list direction.

An empty or expired key is not an error case. LRANGE returns an empty array for a missing range, which the application can translate to “no recent history.”

Append and Trim Belong to One Write Boundary

The recorded implementation used the right three commands for a bounded list, but the notes do not say whether LPUSH and LTRIM were sent atomically. That distinction matters.

If a client performs them as unrelated requests and loses the connection after LPUSH, the new message may be present while the trim never runs. A later successful write can repair the bound, but a quiet conversation could remain oversized.

The hardened write groups the append and trim:

MULTI
LPUSH chat:recent:conversation-7f3 '{"v":1,"messageId":"msg-03","sender":"participant","sentAt":"2026-09-07T07:32:00Z","body":"Example text"}'
LTRIM chat:recent:conversation-7f3 0 49
EXEC

Redis transactions serialize the queued commands and do not serve another client's request in the middle of their execution. They do not provide relational-style rollback for a command that fails at execution time. The key namespace and value type must therefore be controlled so that the list commands cannot encounter an unexpected string, hash, or set. The official Redis transaction documentation describes both guarantees.

This transaction creates a bounded cache entry. It does not make the surrounding WebSocket or broker operation atomic with Redis. These failures remain possible:

  • live delivery succeeds while the cache write fails;
  • the cache write succeeds while live delivery fails;
  • a retried transport stores the same logical message twice;
  • concurrent producers reach Redis in a different order from domain intent.

The response depends on the product contract. A reconnect convenience can tolerate a missing cache entry and collapse duplicate values by messageId. A regulated transcript cannot.

Ordering and Duplicates Need an Explicit Policy

Redis executes commands in server arrival order. That is deterministic at the data structure, but it does not prove business order across application instances.

If two producers append concurrently, LPUSH records whichever command Redis executes last at index zero. Sorting later by client timestamps can introduce a different error when clocks disagree. When strict ordering matters, assign a monotonic sequence in the authoritative write path and include it in the JSON envelope.

A List also permits duplicate values. That is useful when identical text represents distinct messages, but it means transport retries are not automatically deduplicated. A stable messageId lets the read side collapse exact retries. If duplicate prevention must be atomic at write time, the design needs more than three list commands—for example, a deduplication key or a script with its own retention policy.

I would not add that machinery to every replay cache by default. At some point a Redis Stream or durable message table expresses the requirements more honestly than a List.

TTL Was a Separate Decision

The original notes mention TTL as a possible memory-control improvement, not as a verified part of the implementation. That is why I treat it as an optional policy here.

LTRIM limits messages inside one conversation key. It does not limit how many conversation keys can remain. An inactivity TTL can remove the entire replay window after a conversation has been idle:

MULTI
LPUSH chat:recent:conversation-7f3 '{"v":1,"messageId":"msg-03","sender":"participant","sentAt":"2026-09-07T07:32:00Z","body":"Example text"}'
LTRIM chat:recent:conversation-7f3 0 49
EXPIRE chat:recent:conversation-7f3 86400
EXEC

Here, 86,400 seconds is an illustrative one-day inactivity window. Calling EXPIRE on every append resets the window. If expiration should be fixed from key creation instead, the application can use the NX option and test that different contract.

Redis documents that LPUSH changes a list in place and leaves an existing timeout untouched. That is helpful only when the desired TTL is already present; it also means an accidental failure to set the initial TTL will not repair itself. The EXPIRE reference explains the timeout and its conditional options.

TTL is not a substitute for the per-key list bound, and neither is a substitute for a global Redis memory policy. Approximate capacity starts with:

active conversation keys
× retained messages per key
× average serialized message size plus Redis overhead

Measure the actual serialized payload distribution and Redis memory usage rather than relying on the body character count. Configure maxmemory with headroom for replication or persistence buffers, then choose an eviction policy that matches the fact that this data is a cache. Redis's official key eviction documentation describes the available policies and the INFO fields used to observe memory pressure.

If eviction is acceptable, the UI must render an empty replay window without treating it as message corruption. If eviction is unacceptable, this data should not be modeled as an expendable cache.

Verification Focused on Invariants

There is no defensible historical speedup number in the surviving material, so the useful verification is behavioral. I would run the following checks against a real Redis instance used only for tests:

ScenarioInvariant to verify
Append fewer than the limitEvery message is returned once
Append one more than the limitLength stays at the limit and the oldest message is gone
Read after several appendsStorage is newest-first; the adapter returns chronological order
Read an absent keyThe result is an empty collection, not an exception
Write two conversation IDsEach key contains only its own messages
Disconnect between commands in the non-transactional versionThe test exposes a temporarily oversized list
Execute the transactional append and trimA reader cannot observe the list between those two commands
Retry the same logical messageThe chosen messageId policy produces the expected duplicate behavior
Let an optional inactivity TTL elapseThe entire conversation key disappears
Write from concurrent producersThe result is bounded; ordering follows the documented policy

For each test, inspect the retained message IDs, not only LLEN. A list can have the expected length while containing the wrong end of the conversation.

Operational checks should include failed write counts, rejected commands under memory pressure, evictions, key expiration behavior, and payload decode failures. Do not log full message bodies to obtain those metrics.

Where This Design Fits—and Where It Does Not

A bounded Redis List is a good fit when:

  • the product needs only a small reconnect window;
  • recent messages are rebuildable or safe to lose;
  • reads always start from the newest end;
  • exact full-history pagination is unnecessary;
  • a simple per-conversation key matches the authorization model.

It becomes the wrong abstraction when:

  • every message must survive cache loss;
  • users need arbitrary pagination, editing, or search;
  • ordering must be globally authoritative;
  • duplicate prevention must be transactional with another database;
  • retention is governed by audit or legal requirements;
  • one message needs independent delivery and acknowledgment state.

The central design choice was not “Redis is faster than a database.” It was choosing a deliberately incomplete store for a deliberately bounded requirement. LPUSH, LRANGE, and LTRIM made that boundary compact. The harder work was making the direction, failure window, expiration policy, and loss tolerance explicit enough that the cache could not quietly become the system of record.