> ## Documentation Index
> Fetch the complete documentation index at: https://s2.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Redis Streams on S2

> Use Redis clients with S2 streams. Compare command support, understand the guarantees, and configure your connection.

Use Redis clients to append, replay, and tail S2 streams over `RESP2` or `RESP3`. A subset of Redis Streams commands is supported, with S2's durability, consistency, and retention semantics.

<Warning>
  This integration is currently in beta.
</Warning>

## Why Redis Streams on S2?

If you use Redis Streams to deliver live output, S2 keeps that output available for readers who disconnect, [fall behind](#pagination-and-the-tail), or come back later. You get:

* [durable writes](#xadd)
* [streams that grow without bound](#basin-and-stream-configuration), not limited by memory
* [serverless pricing](#billing)

For workloads that keep history around, that can be [an order of magnitude or more cheaper](#retaining-history) than an equivalent provisioned Redis.

Good fits include:

<CardGroup cols={3}>
  <Card title="AI responses and agent runs" icon="robot">
    Stream token chunks, tool calls, and progress updates, and resume missed output when a user returns.
  </Card>

  <Card title="Build logs and background jobs" icon="terminal">
    Stream build, import, or sandbox logs, and debug a completed or failed job later.
  </Card>

  <Card title="Activity feeds" icon="rss">
    Stream customer, device, or session events, and let each reader catch up at its own pace.
  </Card>
</CardGroup>

### When to use something else

* **Other Redis features:** only part of Redis Streams is supported, and no other Redis data types, transactions, scripting, or pub/sub. In particular, there are no consumer groups, explicit `XADD` IDs, exact trimming, `XDEL`, `XREVRANGE`, or `XINFO`. See [command support](#command-support).
* **S2 features beyond Redis:** conditional appends, fencing tokens, and [encrypted streams](/docs/concepts/encryption) need the native [S2 SDKs](/docs/sdk/languages) or [API](/docs/api/protocol).
* **In-memory latency:** S2 acknowledges a write once it is durable in multiple availability zones, within [40 ms on Express and 400 ms on Standard](/docs/concepts/appends#latency) from the same region. If you need sub-millisecond latency, use an in-memory Redis.

<Tip icon="robot">
  <button type="button" data-copy-page-markdown className="font-semibold underline underline-offset-4 cursor-pointer">Copy this guide as Markdown</button> and share it with your coding agent.

  Ask it to review your app's Redis usage, flag compatibility gaps, and assess whether S2 fits your workload.
</Tip>

## Command support

Keys are UTF-8 stream names in the connection's basin. Only the forms below are supported; command names link to the Redis reference.

| Command | Supported forms | Notes |
| - | - | - |
| [`XADD`](https://redis.io/docs/latest/commands/xadd/) | `key * field value [field value ...]` | IDs are always generated. Records are limited to [1 MiB](#xadd). Missing streams need [creation on append](#basin-and-stream-configuration). |
| [`XADD NOMKSTREAM`](https://redis.io/docs/latest/commands/xadd/) | `key NOMKSTREAM * field value ...` | Returns `null` if the stream is missing. The check isn't atomic with the append; see [creation on read](#creation-on-read-and-nomkstream). |
| Pipelined [`XADD`](https://redis.io/docs/latest/commands/xadd/) | Many `XADD`s in flight. | Appended in order [per stream on a connection](#ordering-and-retries). |
| [`XTRIM`](https://redis.io/docs/latest/commands/xtrim/), [`XADD` with trimming](https://redis.io/docs/latest/commands/xadd/) | `MAXLEN ~ threshold`, `MINID ~ id` | Approximate only, and [takes effect eventually](#xtrim-and-xlen). Each trim adds an entry with no fields. No `LIMIT`. |
| [`XRANGE`](https://redis.io/docs/latest/commands/xrange/) | Inclusive and exclusive IDs, timestamp-only IDs, `-`, `+`, `COUNT` | Up to 10,000 entries per call. A short page doesn't mean you've reached the end; see [pagination](#pagination-and-the-tail). |
| [`XREAD`](https://redis.io/docs/latest/commands/xread/) | `COUNT`, `STREAMS` with explicit IDs or `$` | Up to 100 streams, sharing 10,000 entries. No `+` ID or cross-stream snapshot. |
| [`XREAD BLOCK`](https://redis.io/docs/latest/commands/xread/) | `BLOCK milliseconds`; `BLOCK 0` waits indefinitely. | See [blocking reads](#blocking-reads). |
| [`XLEN`](https://redis.io/docs/latest/commands/xlen/) | `key` | Counts [all retained entries](#xtrim-and-xlen), including trim records. |
| [`EXISTS`](https://redis.io/docs/latest/commands/exists/) | One or more keys. | Empty streams count as existing. |
| [`TYPE`](https://redis.io/docs/latest/commands/type/) | One key. | Returns `stream` or `none`. |
| [`AUTH`](https://redis.io/docs/latest/commands/auth/) | `token` | See [authentication](#authentication-and-permissions). |
| [`HELLO`](https://redis.io/docs/latest/commands/hello/) | No arguments, or `2` / `3` with optional `AUTH` and `SETNAME`. | Reports `server` `s2-resp`, Redis-compatible `version` `6.2.0`, and `s2_resp_version`. |
| [`CLIENT SETNAME`](https://redis.io/docs/latest/commands/client-setname/), [`CLIENT GETNAME`](https://redis.io/docs/latest/commands/client-getname/) | Set or get the connection name. | Up to 1,024 bytes of printable ASCII, no spaces. |
| [`CLIENT SETINFO`](https://redis.io/docs/latest/commands/client-setinfo/) | `LIB-NAME value`, `LIB-VER value` | Accepted but not stored. |
| [`SELECT`](https://redis.io/docs/latest/commands/select/) | `0` | Only database 0. |
| [`COMMAND`](https://redis.io/docs/latest/commands/command/) | Any subcommand. | Returns an empty array. |
| [`INFO`](https://redis.io/docs/latest/commands/info/) | Optional section names. | Only `server` (`redis_version:6.2.0`, `redis_mode:standalone`, `s2_resp_version`) and `persistence` (`loading:0`). |
| [`PING`](https://redis.io/docs/latest/commands/ping/), [`ECHO`](https://redis.io/docs/latest/commands/echo/), [`QUIT`](https://redis.io/docs/latest/commands/quit/) | `PING [message]`, `ECHO message`, `QUIT` | Standard replies. |

## Connecting

Connect to `{basin}.r.s2.dev:6380` over TLS 1.3, sending the same hostname as TLS SNI; connections without SNI are rejected. Clients that take a URL should use `rediss://`.

You'll need a [basin](/docs/cli/basins#create-a-basin) and an [access token](/docs/concepts/access-tokens). The examples write to a stream called `events`, so [create it](/docs/cli/streams#create-a-stream) or enable [`create_stream_on_append`](#basin-and-stream-configuration) on the basin.

With [redis-cli](https://redis.io/docs/latest/develop/tools/cli/):

```bash theme={null}
export S2_BASIN="your-basin"
export S2_ACCESS_TOKEN="your-access-token"

REDISCLI_AUTH="$S2_ACCESS_TOKEN" \
redis-cli \
  --tls --sni "${S2_BASIN}.r.s2.dev" \
  -h "${S2_BASIN}.r.s2.dev" -p 6380
```

Then, in the CLI:

```text theme={null}
XADD events * kind example
XRANGE events - + COUNT 100
```

### Client libraries

Each example appends three entries to `events`, reads the stream from the start one [page](#pagination-and-the-tail) at a time, then [tails](#tailing-a-stream) it with `XREAD BLOCK`. They read `S2_BASIN` and `S2_ACCESS_TOKEN` from the environment.

<Accordion title="Python, Node.js, and Go examples">
  <CodeGroup>
    ```python Python (redis-py) theme={null}
    import os

    import redis

    basin = os.environ["S2_BASIN"]
    host = f"{basin}.r.s2.dev"

    r = redis.Redis(
        host=host,
        port=6380,
        ssl=True,
        password=os.environ["S2_ACCESS_TOKEN"],
        decode_responses=True,
        socket_timeout=30,
    )

    stream = "events"

    for i in range(3):
        print("appended", r.xadd(stream, {"kind": "example", "n": str(i)}))

    # Read the full stream, one page at a time.
    last_id = None
    start = "-"
    while True:
        page = r.xrange(stream, min=start, max="+", count=100)
        if not page:
            break
        for entry_id, fields in page:
            print(entry_id, fields)
        last_id = page[-1][0]
        start = f"({last_id}"

    # Tail from the last entry read. The block time must be shorter than socket_timeout.
    cursor = last_id or "0-0"
    while True:
        reply = r.xread({stream: cursor}, count=100, block=10_000)
        for _, entries in reply:
            for entry_id, fields in entries:
                print(entry_id, fields)
                cursor = entry_id
    ```

    ```javascript Node.js (node-redis) theme={null}
    import { createClient } from "redis";

    const basin = process.env.S2_BASIN;
    const host = `${basin}.r.s2.dev`;

    const client = createClient({
      socket: { host, port: 6380, tls: true, servername: host },
      password: process.env.S2_ACCESS_TOKEN,
    });
    client.on("error", (err) => console.error(err));
    await client.connect();

    const stream = "events";

    for (let i = 0; i < 3; i++) {
      console.log("appended", await client.xAdd(stream, "*", { kind: "example", n: String(i) }));
    }

    // Read the full stream, one page at a time.
    let lastId = null;
    let start = "-";
    while (true) {
      const page = await client.xRange(stream, start, "+", { COUNT: 100 });
      if (page.length === 0) break;
      for (const { id, message } of page) console.log(id, message);
      lastId = page.at(-1).id;
      start = `(${lastId}`;
    }

    // Blocking reads hold the connection, so tail on a dedicated one.
    const tail = client.duplicate();
    tail.on("error", (err) => console.error(err));
    await tail.connect();

    let cursor = lastId ?? "0-0";
    while (true) {
      const reply = await tail.xRead({ key: stream, id: cursor }, { COUNT: 100, BLOCK: 10_000 });
      for (const { messages } of reply ?? []) {
        for (const { id, message } of messages) {
          console.log(id, message);
          cursor = id;
        }
      }
    }
    ```

    ```javascript Node.js (ioredis) theme={null}
    import Redis from "ioredis";

    const basin = process.env.S2_BASIN;
    const host = `${basin}.r.s2.dev`;

    const redis = new Redis({
      host,
      port: 6380,
      tls: { servername: host },
      password: process.env.S2_ACCESS_TOKEN,
    });

    const stream = "events";

    for (let i = 0; i < 3; i++) {
      console.log("appended", await redis.xadd(stream, "*", "kind", "example", "n", String(i)));
    }

    // Read the full stream, one page at a time.
    let lastId = null;
    let start = "-";
    while (true) {
      const page = await redis.xrange(stream, start, "+", "COUNT", 100);
      if (page.length === 0) break;
      for (const [id, fields] of page) console.log(id, fields);
      lastId = page.at(-1)[0];
      start = `(${lastId}`;
    }

    // Blocking reads hold the connection, so tail on a dedicated one.
    const tail = redis.duplicate();

    let cursor = lastId ?? "0-0";
    while (true) {
      const reply = await tail.xread("COUNT", 100, "BLOCK", 10_000, "STREAMS", stream, cursor);
      for (const [, entries] of reply ?? []) {
        for (const [id, fields] of entries) {
          console.log(id, fields);
          cursor = id;
        }
      }
    }
    ```

    ```go Go (go-redis) theme={null}
    package main

    import (
    	"context"
    	"crypto/tls"
    	"errors"
    	"fmt"
    	"os"
    	"time"

    	"github.com/redis/go-redis/v9"
    )

    func main() {
    	ctx := context.Background()
    	basin := os.Getenv("S2_BASIN")
    	host := basin + ".r.s2.dev"

    	rdb := redis.NewClient(&redis.Options{
    		Addr:      host + ":6380",
    		Password:  os.Getenv("S2_ACCESS_TOKEN"),
    		TLSConfig: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS13},
    	})
    	defer rdb.Close()

    	stream := "events"

    	for i := range 3 {
    		id, err := rdb.XAdd(ctx, &redis.XAddArgs{
    			Stream: stream,
    			Values: map[string]any{"kind": "example", "n": i},
    		}).Result()
    		if err != nil {
    			panic(err)
    		}
    		fmt.Println("appended", id)
    	}

    	// Read the full stream, one page at a time.
    	lastID := ""
    	start := "-"
    	for {
    		page, err := rdb.XRangeN(ctx, stream, start, "+", 100).Result()
    		if err != nil {
    			panic(err)
    		}
    		if len(page) == 0 {
    			break
    		}
    		for _, msg := range page {
    			fmt.Println(msg.ID, msg.Values)
    		}
    		lastID = page[len(page)-1].ID
    		start = "(" + lastID
    	}

    	// Tail from the last entry read. go-redis extends the read timeout by Block.
    	cursor := lastID
    	if cursor == "" {
    		cursor = "0-0"
    	}
    	for {
    		streams, err := rdb.XRead(ctx, &redis.XReadArgs{
    			Streams: []string{stream, cursor},
    			Count:   100,
    			Block:   10 * time.Second,
    		}).Result()
    		if errors.Is(err, redis.Nil) {
    			continue
    		}
    		if err != nil {
    			panic(err)
    		}
    		for _, s := range streams {
    			for _, msg := range s.Messages {
    				fmt.Println(msg.ID, msg.Values)
    				cursor = msg.ID
    			}
    		}
    	}
    }
    ```
  </CodeGroup>
</Accordion>

Client notes:

* Keep the client's read timeout longer than `BLOCK`. redis-py needs an explicit `socket_timeout`; go-redis extends its read deadline automatically.
* A blocking read holds its connection, so clients that share one connection across commands, such as node-redis and ioredis, need a separate connection for it.

### Authentication and permissions

<Note>
  Each connection is tied to the basin in its hostname, `{basin}.r.s2.dev`, which must also be sent as TLS SNI. Only streams in that basin are reachable; use a separate connection for each basin.
</Note>

Authenticate with `AUTH S2_ACCESS_TOKEN`; in a client library, set the token as the password. No username is needed, though `default` or the basin name work if your client sends one. Authenticate within 30 seconds of connecting.

`AUTH` only checks that the token is valid. Each command is then authorized against the token's [scope](/docs/concepts/access-tokens#scope): the basin, the stream names, and the [operations the command uses](#command-permissions). Anything the token doesn't allow replies `NOPERM`. With [auto-prefixing](/docs/concepts/access-tokens#resources), keys are prefixed too: with the prefix `user1/`, key `logs` is stream `user1/logs`.

### Basin and stream configuration

Basins and streams are configured through the dashboard, [CLI](/docs/cli/basins#reconfigure-a-basin), or API, not through Redis. The [settings](/docs/concepts/configs) that matter here:

* **Creation:** create streams up front, or enable the basin's `create_stream_on_append` so `XADD` creates them. New streams use the basin's default stream config.
* **Retention:** defaults to 7 days; `infinite` keeps everything. Retention beyond the [free-tier limit](/docs/platform/limits#retention) requires a payment method.
* **Storage class:** Express has lower latency than Standard; both are equally durable.
* **Timestamping:** Redis appends carry no client timestamp, so use `arrival` or the default `client-prefer`; `client-require` rejects them.
* **Encryption:** [encrypted streams](/docs/concepts/encryption) need a key on each request, which Redis commands can't pass, so they can't be used here.

#### Creation on read and `NOMKSTREAM`

For a stream that doesn't exist yet:

| Basin setting enabled | `XADD` | `XADD NOMKSTREAM` |
| - | - | - |
| Neither | Error | Returns `null` |
| `create_stream_on_append` | Creates the stream and appends | Returns `null` |
| `create_stream_on_read` | Error | Creates the stream and appends |
| Both | Creates the stream and appends | Creates the stream and appends |

`NOMKSTREAM` checks for the stream before appending, and with `create_stream_on_read` the check itself creates it. The reads made by `XLEN`, `XTRIM`, and inline trimming can also create streams. Because the check and the append are separate, a stream deleted in between can be recreated by `create_stream_on_append`.

### Command permissions

A token needs the [operations](/docs/concepts/access-tokens#operations) a command uses in its `scope.ops`. The table lists what each command can call; with batching and caching, not every call happens every time.

| Command | Possible S2 operations | Permissions to grant |
| - | - | - |
| `AUTH`, `HELLO ... AUTH` | `list-streams` (limit 1) to validate the token | Any valid token; see below |
| `XADD` | `append` | `append` |
| `XADD NOMKSTREAM` | `check-tail`, then `append` | `check-tail`, `append` |
| `XADD` with `MAXLEN ~` or `MINID ~` | `read`, `check-tail`, `append` | `read`, `check-tail`, `append`, `trim` |
| `XTRIM` | `read`, `check-tail`, and `append` if there is anything to trim | `read`, `check-tail`, `append`, `trim` |
| `XRANGE`, nonblocking `XREAD`, `XREAD BLOCK` on several streams | `read`, plus `check-tail` to confirm a cached read is caught up | `read`, `check-tail` |
| `XREAD BLOCK` on one stream | `read` | `read` |
| `XLEN` | `read`, `check-tail` | `read`, `check-tail` |
| `EXISTS`, `TYPE` | `get-stream-config` per key | `get-stream-config` |
| `HELLO`, `CLIENT`, `SELECT`, `COMMAND`, `INFO`, `PING`, `ECHO`, `QUIT` | None | None |

A few details:

* `AUTH` accepts a permission error from its `list-streams` call, so tokens limited to stream operations can still authenticate.
* Grant `check-tail` alongside `read`. A token with only `read` works until a read needs to check the tail.
* `trim` authorizes appending a trim record, so trimming needs both `trim` and `append`. A trim with nothing to remove may use neither.
* Streams created by the basin's creation settings don't need the `create-stream` permission.

Alternatively, grant [operation groups](/docs/concepts/access-tokens#operations): `stream.read` covers `read` and `check-tail`; `stream.write` covers `append`, `trim`, and `fence`; and `basin.read` covers `get-stream-config` and `list-streams`.

## Core semantics

### `XADD`

`XADD key * field value ...` returns an ID once the record is durable in multiple availability zones, on [either storage class](/docs/concepts/appends).

IDs are always generated, as `<timestamp>-<seq_num>`: Unix milliseconds, then the record's S2 sequence number. Sequence numbers only increase; they don't reset when the timestamp changes or after trimming.

Each record can be up to **1 MiB in [metered bytes](/docs/platform/limits#records)**. For `n` field/value pairs:

```text theme={null}
metered bytes = 20 + 2*n + total bytes in all field names and values
```

With inline trimming, the record and its trim record must fit in 1 MiB together.

#### Ordering and retries

Pipelined `XADD`s on one connection are appended in order **per stream**, and replies come back in request order. Appends to different streams, or from different connections, can become visible in any order. Other commands wait for earlier `XADD`s on their connection to finish. A pipeline is not a transaction.

<Note>
  If the connection drops before an `XADD` is acknowledged, the write may or may not have happened. Retrying it can create a duplicate.
</Note>

The service retries an append only when it knows the earlier attempt had no effect; otherwise it closes the connection. There is no idempotency key over Redis. For stronger guarantees, use native [conditional appends](/docs/concepts/concurrency-control).

### `XREAD` and `XRANGE`

Reads return entries in stream order and are [linearizable](/docs/concepts/reads#consistency): once an `XADD` is acknowledged, later reads see it. An `XREAD` across several streams is not a snapshot of all of them at one point in time.

#### Pagination and the tail

**A page with fewer than `COUNT` entries doesn't mean you've caught up.** Pages can end early because of the 10,000-entry cap or size limits. `XREAD` also splits the cap evenly across its streams, so with 100 streams each returns at most 100 entries.

Keep reading until you get an empty result:

* **`XRANGE`:** start the next page at `(last-id`. An empty array means the range is exhausted.
* **Nonblocking `XREAD`:** pass each stream's last returned ID, keeping the previous cursor for streams that returned nothing. `null` means nothing more is available yet.

For example, after a page ending at `1713812735000-42`:

```text theme={null}
XRANGE events (1713812735000-42 + COUNT 100
XREAD COUNT 100 STREAMS events 1713812735000-42
```

Entries with no fields, such as [trim records](#xtrim-and-xlen), are still entries: continue past their IDs.

Store cursors in your application. `$` means "from the current tail", so using it again between reads can skip entries; continue from the returned IDs instead.

#### Blocking reads

`XREAD BLOCK milliseconds` waits for new entries; `BLOCK 0` waits indefinitely. A finite wait includes setting up the read, so a short one can return `null` even when entries exist. Use a nonblocking read to check whether you've caught up, and after a timeout, retry with the same cursors.

Set the client's socket timeout longer than the wait, or disable it for `BLOCK 0`; redis-py 8, for example, defaults to 5 seconds. Use a separate connection if other commands need to run while a read blocks.

#### Tailing a stream

Once a consumer catches up, use `XREAD BLOCK` instead of polling. An empty `XRANGE` or nonblocking `XREAD` near the tail can cost a billed `check-tail`; polling a quiet stream every 100 ms adds about 864,000 operations a day. `XREAD BLOCK` on one stream waits on its open read without tail checks. On several streams, it checks each quiet stream once per command, so longer waits cost less.

### `XTRIM` and `XLEN`

Only approximate trimming is supported: `MAXLEN ~ threshold` and `MINID ~ id`. A trim appends a [command record](/docs/concepts/command-records), which appears in reads as an entry with an empty field array and counts toward `COUNT`, `MAXLEN`, and `XLEN`. Other command records, such as fences, and records written natively without the [Redis encoding](#message-encoding) appear the same way.

[Trimming is eventual](/docs/concepts/trimming): older entries can stay readable for a while after `XTRIM` returns. Its return value estimates how many entries the trim will remove.

For example, with three entries `A`, `B`, and `C`, `XTRIM events MAXLEN ~ 2` returns `2`:

```text theme={null}
Before:          A -> B -> C          XLEN = 3
Trim pending:    A -> B -> C -> []    XLEN can be 4
Trim applied:              C -> []    XLEN = 2
```

`[]` is the trim record. It has its own ID, so continue from it like any other entry. This means `MAXLEN ~ 1` can leave only the trim record, and `MAXLEN ~ 0` can eventually leave the stream empty.

Inline `XADD` trimming appends the entry and the trim record atomically, but concurrent writers can still leave more than `MAXLEN` entries.

`XLEN` counts retained entries, including trim records, and can be briefly off while trimming or retention takes effect. A stream can be empty and still exist: `XLEN` returns 0 while `EXISTS` returns 1. [`delete_on_empty`](/docs/concepts/configs#delete-on-empty) can remove such streams.

[Retention](/docs/concepts/configs#retention) removes old entries independently of `XTRIM`. Once removed, entries can't be read through any interface.

## Interoperability with S2 APIs

Redis and native S2 clients can use the same streams. Trimming, retention, and deletion affect both.

### IDs and positions

In `1713812735000-42`, `1713812735000` is the record's timestamp and `42` is its S2 sequence number. To resume natively after this entry, read from sequence number 43. Timestamps pass through unchanged, so if native writers use a unit other than milliseconds, Redis IDs do too.

### Message encoding

`XADD` writes each field/value pair as a [record header](/docs/concepts/records#anatomy-of-a-record), in order, after a first header `s2-resp.v: 1`. The body is empty:

```text theme={null}
headers: s2-resp.v: 1, field1: value1, field2: value2, ...
body:    empty
```

For example, this entry:

```text theme={null}
> XADD events * sensor t1 temp 21.5
"1713812735000-42"
> XRANGE events 1713812735000-42 1713812735000-42
1) 1) "1713812735000-42"
   2) 1) "sensor"
      2) "t1"
      3) "temp"
      4) "21.5"
```

reads natively as a record with those headers and no body:

```bash theme={null}
s2 read s2://my-basin/events -s 42 -n 1 --format json
```

```json theme={null}
{
  "seq_num": 42,
  "timestamp": 1713812735000,
  "headers": [
    ["s2-resp.v", "1"],
    ["sensor", "t1"],
    ["temp", "21.5"]
  ]
}
```

Use `--format json-base64` for fields or values that aren't valid UTF-8.

Fields and values are binary-safe, and pair order and duplicates are preserved. Headers can't have empty names, so a field name that is empty or starts with a NUL byte is stored with one extra leading NUL byte, which is removed on read.

Native writers can produce Redis entries the same way. Only the first header marks a Redis entry; a later `s2-resp.v` header is an ordinary field. Records without the marker appear in Redis as entries with no fields. A record with the marker and a non-empty body causes a read error.

### Concurrency controls

Append conditions (`match_seq_num` and fencing tokens) are only available through the [native API](/docs/concepts/concurrency-control). Fencing doesn't stop Redis writers, because their appends never carry a token.

## Performance and quality of service

Latency depends on the storage class and your distance from the basin; see [append](/docs/concepts/appends#latency) and [read](/docs/concepts/reads#latency) latency. For throughput, pipeline `XADD`s: the service batches them per stream and connection. `NOMKSTREAM` and inline trimming take extra round trips, so benchmark them separately.

Reuse connections and keep pipeline depth bounded. Fewer, busier connections batch better and can [reuse read sessions](#read-session-reuse). Give blocking readers their own connections, since a slow reply holds up later replies on the same connection.

Under load, the service can slow down accepting commands, reply `TRYAGAIN`, or close connections. Reconnect with backoff.

### Errors and reconnection

| Reply | Meaning | Connection | What to do |
| - | - | - | - |
| `NOAUTH Authentication required.` | The command was sent before `AUTH`. | Stays open | Authenticate, then retry. |
| `NOPERM ...` | The token doesn't allow this operation, stream, or basin. | Stays open | Fix the token's [permissions](#command-permissions). |
| `TRYAGAIN hot server: ... command dropped, retry` | The server was short on memory and dropped the command before running it. | Stays open | Retry the same command. |
| `TRYAGAIN hot server: memory admission timed out; reconnect and retry` | The server was short on memory. | Closed | Reconnect with backoff and retry the command. |
| `ERR server memory limit reached; try again later` | The server refused a new connection. | Closed | Reconnect with backoff. |
| `ERR S2 append outcome is ambiguous; closing connection: ...` | An append may or may not have committed. | Closed | Reconnect; don't blindly replay [unacknowledged writes](#ordering-and-retries). |
| `ERR session closing; blocking read interrupted` | The server is restarting, or the client closed its side of the connection. | Closing | Reconnect and resume from your cursor. |

Other limits:

* A command must arrive in full within 30 seconds and finish within 30 seconds. `XREAD BLOCK` adds its wait to that; `BLOCK 0` has no limit. A command that runs too long closes the connection without a reply.
* A command can be at most 2 MiB with 16,384 arguments. Many small fields can hit these limits before the 1 MiB record limit.
* On restart or scale-in, connections drain for up to 20 seconds and then close. Treat writes without replies as [ambiguous](#ordering-and-retries).

## Billing

Usage is billed at [S2 pricing](https://s2.dev/pricing) for storage, writes, reads, and operations, including the Redis encoding overhead and trim records. Redis commands don't map one-to-one to operations: many `XADD`s can share one append, and some commands make several calls. The [permissions table](#command-permissions) lists the operations each command can make; this includes one `list-streams` call per connection when it authenticates.

### Retaining history

Consider **100 events per second across 32 streams**, each with a 1 KiB value and read once by one consumer per stream. Each column is the estimated monthly cost of retaining that much history:

| Service | 1 day | 7 days | 30 days |
| - | -: | -: | -: |
| Payload retained | 8 GiB | 58 GiB | 247 GiB |
| **S2 Express** | **\$49** | **\$51** | **\$61** |
| Amazon MemoryDB for Valkey, primary only | \$248 | \$1,259 | \$5,034 |
| Amazon ElastiCache for Valkey 9, synchronous durability | \$566 | \$2,263 | \$9,678 |

These are **illustrative cost estimates** for this workload, not measured hosted bills or a benchmark of equivalent latency. Rates were checked on September 28, 2026, using a 730-hour month and AWS on-demand pricing in `us-east-1`. Sources: [S2](https://s2.dev/pricing), [MemoryDB](https://aws.amazon.com/memorydb/pricing/), and [ElastiCache](https://aws.amazon.com/elasticache/pricing/).

<Accordion title="Workload and sizing assumptions">
  The workload uses 32 equal-rate streams, one persistent writer and one dedicated blocking reader per stream. Each event has one field, `d`, and a 1,024-byte value. The consumer reads every event once without additional historical replay. Retention is at steady state after the full retention period has accumulated.

  S2's estimate includes 1,057 [metered bytes](#xadd) per record, retained storage, writes, internet-priced reads, session operations, and resource and authentication charges. It assumes connections and read sessions remain reusable. At 30 days on Express, that is about \$13 for storage, \$19 for writes, \$26 for reads, and \$3 for operations and resources.

  AWS sizing uses approximately 1,385 bytes per record measured in a local Valkey 8.1.1 stream. This is a sizing proxy; hosted memory usage and throughput have not been measured. The model assumes balanced key placement, with 20% memory headroom for MemoryDB and ElastiCache's [default 25% memory reserve](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/redis-memory-management.html).

  Each column uses the cheapest fitting on-demand configuration. MemoryDB uses five `db.t4g.medium` shards at 1 day and one `db.r6g.4xlarge` at 7 days. At 30 days, it uses one `db.r6g.16xlarge`, which costs \$6.8957 per hour. MemoryDB persists writes to a Multi-AZ transaction log, so [replicas are optional](https://docs.aws.amazon.com/memorydb/latest/devguide/components.html); adding one replica per shard for availability doubles its estimate. ElastiCache uses one `cache.r6g.xlarge` shard at 1 day, one `cache.r6g.4xlarge` shard at 7 days, and two `cache.r8g.8xlarge` shards at 30 days, priced including the synchronous-durability premium. Synchronous durability requires a replica per shard, so every ElastiCache estimate includes one. ElastiCache's [synchronous durability](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/Durability.Configuring.html) acknowledges writes after persisting them to a Multi-AZ transaction log; this mode [does not support Serverless or data tiering](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/Durability.Limitations.html). The MemoryDB stream keys exceed its [128 MiB tiering threshold](https://docs.aws.amazon.com/memorydb/latest/devguide/data-tiering-prerequisites.html), so the model keeps their history in memory.

  Estimates exclude taxes, promotional credits, commitment discounts, additional snapshots, and application-side compute and network charges. Different memory footprints, reconnects, polling, and unused read-ahead can change the result.
</Accordion>

In practice, teams running Redis Streams rarely keep a month of history in memory. Instead, they trim each stream to a short window with `XTRIM` and run a consumer that archives entries to object storage. With a 1-day window, that costs roughly the 1-day column above plus about \$6 a month to keep 247 GiB in [S3 Standard](https://aws.amazon.com/s3/pricing/). The cost is operational: an archiver to run and monitor, two read paths, and replays that must hand off from the archive to the stream at the right ID without gaps or duplicates. With S2, the history stays in the stream and every reader uses the same commands.

Shorter retention or frequent rereading narrows the cost gap. Compare [latency and throughput requirements](#performance-and-quality-of-service) as well as cost.

### Read-session reuse

Reading a stream sequentially on one connection reuses the same S2 read session, which is [billed by the minute](https://s2.dev/pricing). Reconnecting, jumping to a different cursor, or switching between blocking and nonblocking reads can open a new session.

Data the service reads ahead is billed even if the client never requests it. Read transfer is priced by the client's network path; connecting through the public endpoint doesn't qualify for private-network pricing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.