Chapter 45 - Relay Ops
45
Nostr Implementation Possibility - Hex № 45

Event Counts

Ask a relay how many events match a filter. Get a number back, not ten thousand events.

1 Overview

Some numbers are expensive to compute on the client. To show a follower count, a client must download every kind-3 follow list that references a pubkey. Then it counts them and throws them away. That wastes bandwidth on both sides.

Clients can cache the result. A separate indexing server can also cache it. Both options erode the decentralization of the network. They create a second-layer protocol on top of Nostr.

This NIP moves the counting to the relay. A relay may support the verb COUNT. The client sends a query id and one or more NIP-01 filters. The relay answers with a single integer. An optional HyperLogLog extension lets clients merge counts from many relays into one estimate.

Hint!

COUNT is optional relay hardware. Probe before you rely on it. If a relay does not implement the verb, expect a NOTICE or silence, and fall back to counting raw events yourself.

Client Relay ["COUNT", "q1", {"kinds":[3],"#p":["…"]}] ["COUNT", "q1", {"count":16578}] ["CLOSED", "q2", "auth-required: …"] on refusal
Fig. 45-1 — The COUNT round trip. One request, one number back. A refusal comes as CLOSED.

2 The COUNT Verb

The client sends a COUNT message. It carries a query id and one or more filters. The filters follow the same rules as filters for the NIP-01 verb REQ. Multiple filters are OR'd together. The relay aggregates all matches into a single count result.

Wire format - COUNT request
["COUNT", <query_id>, <filters JSON>...]

The relay answers with a COUNT response on the same query id. The payload is a JSON object with a count key. A relay may use probabilistic counting to reduce compute cost. If it does, it may set the approximate key in the response.

Wire format - COUNT response
["COUNT", <query_id>, {"count": <integer>}]

-- with optional flags --
["COUNT", <query_id>, {"count": <integer>, "approximate": <true|false>}]
["COUNT", <query_id>, {"count": <integer>, "hll": "<hex>"}]
COUNT response payload
KeyTypeRule
countintegerThe number of events that match the OR'd filters. Required.
approximatebooleanOptional. May be set when the relay used probabilistic counting.
hllhex string, 512 charsOptional. HyperLogLog registers for this count. See §3.

A relay can refuse to fulfill a COUNT request. Whenever it refuses, it must return a CLOSED message for that query id. The CLOSED shape and its status prefixes come from NIP-01. A relay that requires login first uses the auth-required prefix from NIP-42.

Tech Note

The source spec is inconsistent about the second element's name. The general sections call it <query_id>; the HyperLogLog sections and two examples still call it <subscription_id>. A 2025 change renamed it to "query id" because COUNT is not a subscription, but the rename missed the HLL sections. Both names denote the same field.

3 HyperLogLog

A count from one relay is a local truth. Users read from many relays, and the same reaction can sit on several of them. Plain integers from different relays cannot be merged without double counting.

HyperLogLog (HLL) fixes this. It is a probabilistic sketch: a small fixed-size register array that estimates the number of distinct items. A relay may return an HLL value together with the count, hex-encoded, in the hll key. Clients merge the HLL values from multiple relays and get one reasonable estimate for reaction counts, comment counts, and follower counts. This saves many millions of bytes of bandwidth for everybody.

Relay-side algorithm

This is the only part needed for interoperability. Follow these steps to build the HLL value for a filter:

  1. Receive the filter. Check that it is eligible for HLL (see the offset rules below). Compute the deterministic offset for the filter.
  2. Initialize 256 registers to 0.
  3. For every event that the filter counts, do this:
    1. Read the byte at position offset of the event pubkey. That byte value is the register index ri.
    2. Count the leading zero bits starting at position offset+1 of the event pubkey. Add 1.
    3. Compare the result with the value stored at register ri. If the new number is bigger, store it.

On the client side, merging is simple. Walk through all 256 registers of each HLL value from each relay. Keep the highest value for each register, regardless of the relay. Then estimate the absolute count from the merged registers.

Relay B hll: 06 07 07 05 … Relay C hll: 01 ef 07 05 … Merge max per register Estimate count ≈ 16578 COUNT 256 regs
Fig. 45-2 — Mergeable counts. Register-wise maximum joins sketches from many relays without double counting.

Computing the offset

The offset used above is derived deterministically from the filter that the client sent. Compute it like this:

  1. Take the first tag attribute in the filter. Tag attributes carry the # prefix, for example #p.
  2. From that attribute, take its first item. It is a string.
  3. Obtain a 32-byte hex string from it:
    • If the string is an event id or a pubkey in hex, use it as it is.
    • If the string is an address (<kind>:<pubkey>:<d-tag>), use the <pubkey> part.
    • If the string is anything else, hash it with sha256(). Take the result as a hex string.
  4. From that 64-character hex string, take the character at position 32.
  5. Read that character as a base-16 number.
  6. Add 8 to it. The result is the offset.

For cases not covered above, such as filters without any tag attribute, behavior is not yet defined. This NIP may be modified later to specify those cases. For now no use case justifies HLL there.

Filter first #tag item Normalize 32-byte hex Char [32] base-16 digit + 8 = offset
Fig. 45-3 — Offset pipeline. The offset lands between 8 and 23, always inside the 32-byte pubkey.

Rationale. The offset must be deterministic. That is the only way a relay can cache HLL values instead of counting thousands of database rows on every query. It also lets a relay precompute HLL values for any target <id> or <pubkey> without storing the events themselves. That is handy for reactions, for example.

Common filters

Some relays cache or precompute HLL values only for common canonical queries. They may refuse to count anything else. Each query below uses exactly one tag attribute with a single item, so implementors do not need to check attribute order. This list can grow later if more common useful queries appear:

Canonical HLL-eligible queries
CountsFilterKind source
reactions{"#e": ["<id>"], "kinds": [7]}NIP-25
reposts{"#e": ["<id>"], "kinds": [6]}NIP-18
quotes{"#q": ["<id>"], "kinds": [1, 1111]}NIP-10, NIP-22
replies{"#e": ["<id>"], "kinds": [1]}NIP-10
comments{"#E": ["<id>"], "kinds": [1111]}NIP-22
followers{"#p": ["<pubkey>"], "kinds": [3]}NIP-02

The hll encoding

The hll value is the concatenation of the 256 registers. Each register is one uint8 byte. Therefore hll is always a 512-character hex string.

Warning!

Attack vector: an attacker can mine a pubkey with many zero bits exactly where the HLL algorithm looks. One reaction from that pubkey then "counts more" than others. The attacker needs a fresh mined pubkey per target, which is close to plain sybil spam anyway. The fix is the same for both attacks: do not fetch counts from open relays that accept everything. Base counts on relays that filter, so mostly real humans publish there, not bots or artificially generated pubkeys.

Client-side usage

Clients can combine HLL responses from relays with HLL sketches computed locally from raw events. Keep a local HLL per counter. Add every incoming source to it. An example flow for tracking reactions to an event Z over time:

  1. The client reads reactions from relays A, B, and C — the NIP-65 "read" relays of Z's author.
  2. Only B and C support HLL responses. The client fetches both and merges them locally.
  3. The client fetches all raw reaction events from A. It applies each event to the merged HLL with the same relay-side algorithm.
  4. The client reads the estimated count from the HLL and displays it to the user.
  5. Optionally, the client stores the HLL value together with a last-read date for relay A. On the next refresh it fetches only the new reactions from A, applies them, redownloads the HLL values from B and C, and reapplies those.

This procedure lets the client download much less data.

Tech Note

The estimation step is deliberately unspecified. The source spec points to implementation code instead of a formula and allows different estimators "with different quirks". Two conforming clients can therefore show slightly different numbers from identical registers. The zero-bit count in step 3.2 also has no stated cap, though 256 registers with uint8 values implies one.

4 Worked Examples

The session below shows every response shape. Orange lines go to the relay. Blue lines come from the relay. The 512-character hll strings are shortened here; a real one is never truncated.

Session transcript - wss://relay.example.com
-- count notes and reactions by one author --
→ ["COUNT", "q1", {"kinds": [1, 7], "authors": ["22a12a12…"]}]
← ["COUNT", "q1", {"count": 5}]

-- count all notes, relay answers probabilistically --
→ ["COUNT", "q2", {"kinds": [1]}]
← ["COUNT", "q2", {"count": 93412452, "approximate": true}]

-- follower count with HyperLogLog --
→ ["COUNT", "q3", {"kinds": [3], "#p": ["f7234bd4…"]}]
← ["COUNT", "q3", {"count": 16578, "hll": "0607070505060806…0405070708"}]

-- reaction count with HyperLogLog --
→ ["COUNT", "q4", {"kinds": [7], "#e": ["5c83da77…"]}]
← ["COUNT", "q4", {"count": 2044, "hll": "01ef070505060806…0405070708"}]

-- relay refuses to count gift wraps --
→ ["COUNT", "q5", {"kinds": [1059], "#p": ["f7234bd4…"]}]
← ["CLOSED", "q5", "auth-required: cannot count other people's DMs"]

In the last exchange the relay protects kind-1059 gift wraps (NIP-59). It demands NIP-42 authentication before it counts private messages.

5 Version History

NIP-45 has lived through a deprecation and a resurrection. Notable changes, newest first:

  • 20262026-02Added the HyperLogLog extension: hll responses, the register algorithm, the offset formula, canonical queries, and the mined-pubkey attack note.
  • 20252025-10Renamed the subscription id to a query id. COUNT is a one-shot query, not a subscription.
  • 20242024-07Restored. The earlier removal was reverted and COUNT returned to the spec.
  • 20232023-12Refusals standardized: a relay that declines a COUNT must reply with CLOSED.
  • 20232023-10Deprecated and slated for removal, before the 2024 revert brought it back.
  • 20232023-08Added the approximate key so relays can flag probabilistic counts.
  • 20232023-05Clarified multi-filter behavior: filters are OR'd and aggregated into one count.
  • 20232023-04Counting by arbitrary filters spelled out; grammar and formatting fixes.
  • Origin2023-01Jonathan Staab added NIP-45, defining the COUNT verb.

Original Proposal - Credits

AuthorJonathan Staab (hodlbod)
First commit2023-01-03 — “Add NIP-45, which defines a COUNT verb”
Statusdraft - optional - relay feature
Source45.md on GitHub - full change history

6 See Also

  • NIP-01 — defines the filters that COUNT reuses, and the CLOSED message for refusals.
  • NIP-42 — client authentication; source of the auth-required refusal prefix.
  • NIP-02 — kind-3 follow lists, the raw material for follower counts.
  • NIP-25 — kind-7 reactions, the most counted event kind.
  • NIP-18 — kind-6 reposts and quote conventions.
  • NIP-22 — kind-1111 comments counted by the comment and quote queries.
  • NIP-65 — relay lists that tell a client where to count from.