Parrhesia

Parrhesia Logo

Parrhesia is a Nostr relay server written in Elixir/OTP.

BETA CONDITION – BREAKING CHANGES MAY STILL HAPPEN!

Supported storage backends:

Advanced Nostr features:

It exposes:

Listeners can run in plain HTTP, HTTPS, mutual TLS, or proxy-terminated TLS modes. The current TLS implementation supports:

Supported NIPs

Current supported_nips list:

1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 66, 70, 77, 86, 98

43 is advertised when the built-in NIP-43 relay access flow is enabled. Parrhesia generates relay-signed 28935 invite responses on REQ, validates join and leave requests locally, and publishes the resulting signed 8000, 8001, and 13534 relay membership events into its own local event store.

50 uses ranked PostgreSQL full-text search over event content by default. Parrhesia applies the filter limit after ordering by match quality, and falls back to trigram-backed substring matching for short or symbol-heavy queries such as search-as-you-type prefixes, domains, and punctuation-rich tokens.

66 is advertised when the built-in NIP-66 publisher is enabled and has at least one relay target. The default config enables it for the public relay URL. Parrhesia probes those target relays, collects the resulting NIP-11 / websocket liveness data, and then publishes the signed 10166 and 30166 events locally on this relay.

Requirements


Command runner (just)

This repo includes a justfile that provides a grouped command/subcommand CLI over common mix tasks and scripts.

just
just help bench
just help e2e

Run locally

1) Prepare the database

Parrhesia uses these defaults in dev:

Create the DB and run migrations/seeds:

mix setup

2) Start the server

mix run --no-halt

The default public listener binds to http://localhost:4413.

WebSocket clients should connect to:

ws://localhost:4413/relay

Useful endpoints


Test suites

Primary test entrypoints:

The node-sync harnesses are driven by:

just e2e node-sync runs two real Parrhesia nodes against separate PostgreSQL databases, verifies catch-up and live sync, restarts one node, and verifies persisted resume behavior. just e2e node-sync-docker runs the same scenario against the release Docker image.

GitHub CI currently runs the non-Docker node-sync e2e on the main Linux matrix job. The Docker node-sync e2e remains an explicit/manual check because it depends on release-image build/runtime fidelity and a working Docker host.


Embedding in another Elixir app

Parrhesia is usable as an embedded OTP dependency, not just as a standalone relay process. The intended in-process surface is Parrhesia.API.*, especially:

For host-managed HTTP/WebSocket ingress mounting, use Parrhesia.Plug.

Start with:

Important caveats for host applications:

Official embedding boundary

For embedded use, the stable boundaries are:

If your host app owns the public HTTPS endpoint, keep this as the baseline runtime config:

config :parrhesia, :listeners, %{}

Notes:

The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes.


Production configuration

Minimal setup

Before a Nostr client can publish its first event successfully, make sure these pieces are in place:

  1. PostgreSQL is reachable from Parrhesia. Set DATABASE_URL and create/migrate the database with Parrhesia.Release.migrate() or mix ecto.migrate.

    PostgreSQL is the supported production datastore. The in-memory backend is intended for non-persistent runs such as tests and benchmarks.

  2. Parrhesia listeners are configured for your deployment. The default config exposes a public listener on plain HTTP port 4413, and a reverse proxy can terminate TLS and forward WebSocket traffic to /relay. Additional listeners can be defined in config/*.exs.

  3. :relay_url matches the public relay URL clients should use. Set PARRHESIA_RELAY_URL to the public relay URL exposed by the reverse proxy. In the normal deployment model, this should be your public wss://.../relay URL.

  4. The database schema is migrated before starting normal traffic. The app image does not auto-run migrations on boot.

That is the actual minimum. With default policy settings, writes do not require auth, event signatures are verified, and no extra Nostr-specific bootstrap step is needed before posting ordinary events.

In prod, these environment variables are used:

config/runtime.exs reads these values at runtime in production releases.

Runtime env naming

For runtime overrides, use the PARRHESIA_... prefix:

Examples:

export PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES=true
export PARRHESIA_METRICS_ALLOWED_CIDRS="10.0.0.0/8,192.168.0.0/16"
export PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY=drop_oldest

Listeners themselves are primarily configured under config :parrhesia, :listeners, .... The current runtime env helpers tune the default public listener and the optional dedicated metrics listener, including their connection ceilings.

For settings that are awkward to express as env vars, mount an extra config file and set PARRHESIA_EXTRA_CONFIG to its path inside the container.

Config reference

CSV env vars use comma-separated values. Boolean env vars accept 1/0, true/false, yes/no, or on/off.

Top-level :parrhesia

Atom key ENV Default Notes
:relay_urlPARRHESIA_RELAY_URLws://localhost:4413/relay Advertised relay URL and auth relay tag target
:metadata.hide_version?PARRHESIA_METADATA_HIDE_VERSIONtrue Hides the relay version from outbound User-Agent and NIP-11 when enabled
:acl.protected_filtersPARRHESIA_ACL_PROTECTED_FILTERS[] JSON-encoded protected filter list for sync ACL checks
:identity.pathPARRHESIA_IDENTITY_PATHnil Optional path for persisted relay identity material
:identity.private_keyPARRHESIA_IDENTITY_PRIVATE_KEYnil Optional inline relay private key
:moderation_cache_enabledPARRHESIA_MODERATION_CACHE_ENABLEDtrue Toggle moderation cache
:enable_expiration_workerPARRHESIA_ENABLE_EXPIRATION_WORKERtrue Toggle background expiration worker
:nip43 config-file driven see table below Built-in NIP-43 relay access invite / membership flow
:nip66 config-file driven see table below Built-in NIP-66 discovery / monitor publisher
:sync.pathPARRHESIA_SYNC_PATHnil Optional path to sync peer config
:sync.start_workers?PARRHESIA_SYNC_START_WORKERStrue Start outbound sync workers on boot
:sync.relay_guardPARRHESIA_SYNC_RELAY_GUARDfalse Suppress multi-node re-fanout for sync-originated events
:limitsPARRHESIA_LIMITS_* see table below Runtime override group
:policiesPARRHESIA_POLICIES_* see table below Runtime override group
:listeners config-file driven see notes below Ingress listeners with bind, transport, feature, auth, network, and baseline ACL settings
:retentionPARRHESIA_RETENTION_* see table below Partition lifecycle and pruning policy
:featuresPARRHESIA_FEATURES_* see table below Runtime override group
:storage.events-Parrhesia.Storage.Adapters.Postgres.Events Config-file override only
:storage.moderation-Parrhesia.Storage.Adapters.Postgres.Moderation Config-file override only
:storage.groups-Parrhesia.Storage.Adapters.Postgres.Groups Config-file override only
:storage.admin-Parrhesia.Storage.Adapters.Postgres.Admin Config-file override only

Parrhesia.Repo

Atom key ENV Default Notes
:urlDATABASE_URL required Example: ecto://USER:PASS@HOST/DATABASE
:pool_sizePOOL_SIZE32 DB connection pool size
:queue_targetDB_QUEUE_TARGET_MS1000 Ecto queue target in ms
:queue_intervalDB_QUEUE_INTERVAL_MS5000 Ecto queue interval in ms
:types-Parrhesia.PostgresTypes Internal config-file setting

Parrhesia.ReadRepo

Atom key ENV Default Notes
:urlDATABASE_URL required Shares the primary DB URL with the write repo
:pool_sizeDB_READ_POOL_SIZE32 Read-only query pool size
:queue_targetDB_READ_QUEUE_TARGET_MS1000 Read pool Ecto queue target in ms
:queue_intervalDB_READ_QUEUE_INTERVAL_MS5000 Read pool Ecto queue interval in ms
:types-Parrhesia.PostgresTypes Internal config-file setting

:listeners

Atom key ENV Default Notes
:public.bind.portPORT4413 Default public listener port
:public.max_connectionsPARRHESIA_PUBLIC_MAX_CONNECTIONS20000 Target total connection ceiling for the public listener
:public.proxy.trusted_cidrsPARRHESIA_TRUSTED_PROXIES[] Trusted reverse proxies for forwarded IP handling
:public.features.metrics.*PARRHESIA_METRICS_* see below Convenience runtime overrides for metrics on the public listener
:metrics.bind.portPARRHESIA_METRICS_ENDPOINT_PORT9568 Optional dedicated metrics listener port
:metrics.max_connectionsPARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS1024 Target total connection ceiling for the dedicated metrics listener
:metrics.enabledPARRHESIA_METRICS_ENDPOINT_ENABLEDfalse Enables the optional dedicated metrics listener

Listener max_connections is a first-class config field. Parrhesia translates it to ThousandIsland's per-acceptor num_connections limit based on the active acceptor count. Raw bandit_options[:thousand_island_options] can still override that for advanced tuning.

Listener transport.tls supports :disabled, :server, :mutual, and :proxy_terminated. For TLS-enabled listeners, the main config-file fields are certfile, keyfile, optional cacertfile, optional cipher_suite, optional client_pins, and proxy_headers for proxy-terminated identity.

Every listener supports this config-file schema:

Atom key ENV Default Notes
:id- listener key or :listener Listener identifier
:enabled public/metrics helpers only true Whether the listener is started
:bind.ip-0.0.0.0 (public) / 127.0.0.1 (metrics) Bind address
:bind.portPORT / PARRHESIA_METRICS_ENDPOINT_PORT4413 / 9568 Bind port
:max_connectionsPARRHESIA_PUBLIC_MAX_CONNECTIONS / PARRHESIA_METRICS_ENDPOINT_MAX_CONNECTIONS20000 / 1024 Target total listener connection ceiling; accepts integer or :infinity in config files
:transport.scheme-:http Listener scheme
:transport.tls-%{mode: :disabled} TLS mode and TLS-specific options
:proxy.trusted_cidrsPARRHESIA_TRUSTED_PROXIES on public[] Trusted proxy CIDRs for forwarded identity / IP handling
:proxy.honor_x_forwarded_for-true Respect X-Forwarded-For from trusted proxies
:network.public-false Allow only public networks
:network.private_networks_only-false Allow only RFC1918 / local networks
:network.allow_cidrs-[] Explicit CIDR allowlist
:network.allow_all-true Allow all source IPs
:features.nostr.enabled-true on public, false on metrics listener Enables /relay
:features.admin.enabled-true on public, false on metrics listener Enables /management
:features.metrics.enabledPARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT on publictrue on public, true on metrics listener Enables /metrics
:features.metrics.auth_tokenPARRHESIA_METRICS_AUTH_TOKENnil Optional bearer token for /metrics
:features.metrics.access.publicPARRHESIA_METRICS_PUBLICfalse Allow public-network access to /metrics
:features.metrics.access.private_networks_onlyPARRHESIA_METRICS_PRIVATE_NETWORKS_ONLYtrue Restrict /metrics to private networks
:features.metrics.access.allow_cidrsPARRHESIA_METRICS_ALLOWED_CIDRS[] Additional CIDR allowlist for /metrics
:features.metrics.access.allow_all-true Unconditional metrics access in config files
:auth.nip42_required-false Require NIP-42 for relay reads / writes
:auth.nip98_required_for_adminPARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIRED on publictrue Require NIP-98 for management API calls
:baseline_acl.read-[] Static read deny/allow rules
:baseline_acl.write-[] Static write deny/allow rules
:bandit_options-[] Advanced Bandit / ThousandIsland passthrough

:nip66

Atom key ENV Default Notes
:enabled-true Enables the built-in NIP-66 publisher worker
:publish_interval_seconds-900 Republish cadence for 10166 and 30166 events
:publish_monitor_announcement?-true Publish a 10166 monitor announcement alongside discovery events
:timeout_ms-5000 Probe timeout for websocket and NIP-11 checks
:checks-[:open, :read, :nip11] Checks advertised in 10166 and run against each target relay during probing
:targets-[] Optional explicit relay targets to probe; when empty, Parrhesia uses :relay_url for the public listener

NIP-66 targets are probe sources, not publish destinations. Parrhesia connects to each target relay, collects the configured liveness / discovery data, and stores the resulting signed 10166 / 30166 events in its own local event store so clients can query them here.

:nip43

Atom key ENV Default Notes
:enabled-true Enables the built-in NIP-43 relay access flow and advertises 43 in NIP-11
:invite_ttl_seconds-900 Expiration window for generated invite claim strings returned by REQ filters targeting kind 28935
:request_max_age_seconds-300 Maximum allowed age for inbound join (28934) and leave (28936) requests

Parrhesia treats NIP-43 invite requests as synthetic relay output, not stored client input. A REQ for kind 28935 causes the relay to generate a fresh relay-signed invite event on the fly. Clients then submit that claim back in a protected kind 28934 join request. When a join or leave request is accepted, Parrhesia updates its local relay membership state and publishes the corresponding relay-signed 8000 / 8001 delta plus the latest 13534 membership snapshot locally.

:limits

Atom key ENV Default
:max_frame_bytesPARRHESIA_LIMITS_MAX_FRAME_BYTES1048576
:max_event_bytesPARRHESIA_LIMITS_MAX_EVENT_BYTES262144
:max_filters_per_reqPARRHESIA_LIMITS_MAX_FILTERS_PER_REQ16
:max_filter_limitPARRHESIA_LIMITS_MAX_FILTER_LIMIT500
:max_tags_per_eventPARRHESIA_LIMITS_MAX_TAGS_PER_EVENT256
:max_tag_values_per_filterPARRHESIA_LIMITS_MAX_TAG_VALUES_PER_FILTER128
:ip_max_event_ingest_per_windowPARRHESIA_LIMITS_IP_MAX_EVENT_INGEST_PER_WINDOW1000
:ip_event_ingest_window_secondsPARRHESIA_LIMITS_IP_EVENT_INGEST_WINDOW_SECONDS1
:relay_max_event_ingest_per_windowPARRHESIA_LIMITS_RELAY_MAX_EVENT_INGEST_PER_WINDOW10000
:relay_event_ingest_window_secondsPARRHESIA_LIMITS_RELAY_EVENT_INGEST_WINDOW_SECONDS1
:max_subscriptions_per_connectionPARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION32
:max_event_future_skew_secondsPARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS900
:max_event_ingest_per_windowPARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW120
:event_ingest_window_secondsPARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS1
:auth_max_age_secondsPARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS600
:websocket_ping_interval_secondsPARRHESIA_LIMITS_WEBSOCKET_PING_INTERVAL_SECONDS30
:websocket_pong_timeout_secondsPARRHESIA_LIMITS_WEBSOCKET_PONG_TIMEOUT_SECONDS10
:max_outbound_queuePARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE256
:outbound_drain_batch_sizePARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE64
:outbound_overflow_strategyPARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY:close
:max_negentropy_payload_bytesPARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES4096
:max_negentropy_sessions_per_connectionPARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION8
:max_negentropy_total_sessionsPARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS10000
:max_negentropy_items_per_sessionPARRHESIA_LIMITS_MAX_NEGENTROPY_ITEMS_PER_SESSION50000
:negentropy_id_list_thresholdPARRHESIA_LIMITS_NEGENTROPY_ID_LIST_THRESHOLD32
:negentropy_session_idle_timeout_secondsPARRHESIA_LIMITS_NEGENTROPY_SESSION_IDLE_TIMEOUT_SECONDS60
:negentropy_session_sweep_interval_secondsPARRHESIA_LIMITS_NEGENTROPY_SESSION_SWEEP_INTERVAL_SECONDS10

:policies

Atom key ENV Default
:auth_required_for_writesPARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITESfalse
:auth_required_for_readsPARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READSfalse
:min_pow_difficultyPARRHESIA_POLICIES_MIN_POW_DIFFICULTY0
:accept_ephemeral_eventsPARRHESIA_POLICIES_ACCEPT_EPHEMERAL_EVENTStrue
:mls_group_event_ttl_secondsPARRHESIA_POLICIES_MLS_GROUP_EVENT_TTL_SECONDS300
:marmot_require_h_for_group_queriesPARRHESIA_POLICIES_MARMOT_REQUIRE_H_FOR_GROUP_QUERIEStrue
:marmot_group_max_h_values_per_filterPARRHESIA_POLICIES_MARMOT_GROUP_MAX_H_VALUES_PER_FILTER32
:marmot_group_max_query_window_secondsPARRHESIA_POLICIES_MARMOT_GROUP_MAX_QUERY_WINDOW_SECONDS2592000
:marmot_media_max_imeta_tags_per_eventPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_IMETA_TAGS_PER_EVENT8
:marmot_media_max_field_value_bytesPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_FIELD_VALUE_BYTES1024
:marmot_media_max_url_bytesPARRHESIA_POLICIES_MARMOT_MEDIA_MAX_URL_BYTES2048
:marmot_media_allowed_mime_prefixesPARRHESIA_POLICIES_MARMOT_MEDIA_ALLOWED_MIME_PREFIXES[]
:marmot_media_reject_mip04_v1PARRHESIA_POLICIES_MARMOT_MEDIA_REJECT_MIP04_V1true
:marmot_push_server_pubkeysPARRHESIA_POLICIES_MARMOT_PUSH_SERVER_PUBKEYS[]
:marmot_push_max_relay_tagsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_RELAY_TAGS16
:marmot_push_max_payload_bytesPARRHESIA_POLICIES_MARMOT_PUSH_MAX_PAYLOAD_BYTES65536
:marmot_push_max_trigger_age_secondsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_TRIGGER_AGE_SECONDS120
:marmot_push_require_expirationPARRHESIA_POLICIES_MARMOT_PUSH_REQUIRE_EXPIRATIONtrue
:marmot_push_max_expiration_window_secondsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_EXPIRATION_WINDOW_SECONDS120
:marmot_push_max_server_recipientsPARRHESIA_POLICIES_MARMOT_PUSH_MAX_SERVER_RECIPIENTS1
:management_auth_requiredPARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIREDtrue

Listener-related Metrics Helpers

Atom key ENV Default
:public.features.metrics.enabledPARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINTtrue
:publicPARRHESIA_METRICS_PUBLICfalse
:private_networks_onlyPARRHESIA_METRICS_PRIVATE_NETWORKS_ONLYtrue
:allowed_cidrsPARRHESIA_METRICS_ALLOWED_CIDRS[]
:auth_tokenPARRHESIA_METRICS_AUTH_TOKENnil

:retention

Atom key ENV Default Notes
:check_interval_hoursPARRHESIA_RETENTION_CHECK_INTERVAL_HOURS24 Partition maintenance + pruning cadence
:months_aheadPARRHESIA_RETENTION_MONTHS_AHEAD2 Pre-create current month plus N future monthly partitions for events and event_tags
:max_db_bytesPARRHESIA_RETENTION_MAX_DB_BYTES:infinity Interpreted as GiB threshold; accepts integer or infinity
:max_months_to_keepPARRHESIA_RETENTION_MAX_MONTHS_TO_KEEP:infinity Keep at most N months (including current month); accepts integer or infinity
:max_partitions_to_drop_per_runPARRHESIA_RETENTION_MAX_PARTITIONS_TO_DROP_PER_RUN1 Safety cap for each maintenance run

:features

Atom key ENV Default
:verify_event_signatures-true
:nip_45_countPARRHESIA_FEATURES_NIP_45_COUNTtrue
:nip_50_searchPARRHESIA_FEATURES_NIP_50_SEARCHtrue
:nip_77_negentropyPARRHESIA_FEATURES_NIP_77_NEGENTROPYtrue
:marmot_push_notificationsPARRHESIA_FEATURES_MARMOT_PUSH_NOTIFICATIONSfalse

:verify_event_signatures is config-file only. Production releases always verify event signatures.

Extra runtime config

Atom key ENV Default Notes
extra runtime config file PARRHESIA_EXTRA_CONFIG unset Imports an additional runtime .exs file

Deploy

Option A: Elixir release

export MIX_ENV=prod
export DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod"
export POOL_SIZE=20

mix deps.get --only prod
mix compile
mix release

_build/prod/rel/parrhesia/bin/parrhesia eval "Parrhesia.Release.migrate()"
_build/prod/rel/parrhesia/bin/parrhesia start

For systemd/process managers, run the release command with start.

Option B: Nix release package (default.nix)

Build:

nix build

Run the built release from ./result/bin/parrhesia (release command interface).

Option C: Docker image via Nix flake

Build the image tarball:

nix build .#dockerImage
# or with explicit build target:
nix build .#packages.x86_64-linux.dockerImage

Load it into Docker:

docker load < result

Run database migrations:

docker run --rm \
  -e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
  parrhesia:latest \
  eval "Parrhesia.Release.migrate()"

Start the relay:

docker run --rm \
  -p 4413:4413 \
  -e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
  -e POOL_SIZE=20 \
  parrhesia:latest

Option D: Docker Compose with PostgreSQL

The repo includes compose.yaml and .env.example so Docker users can run Postgres and Parrhesia together.

Set up the environment file:

cp .env.example .env

If you are building locally from source, build and load the image first:

nix build .#dockerImage
docker load < result

Then start the stack:

docker compose up -d db
docker compose run --rm migrate
docker compose up -d parrhesia

The relay will be available on:

ws://localhost:4413/relay

Notes:


Benchmark

The benchmark compares two Parrhesia profiles, one backed by PostgreSQL and one backed by the in-memory adapter, against strfry and nostr-rs-relay using nostr-bench. The cloud benchmark target set also includes nostream and Haven. Benchmark runs also lift Parrhesia's relay-side limits by default so the benchmark client, not server guardrails, is the main bottleneck.

just bench compare is a sequential mixed-workload benchmark, not an isolated per-endpoint microbenchmark. Each relay instance runs connect, then echo, then event, then req against the same live process, so later phases measure against state and load created by earlier phases.

Run it with:

just bench compare

Cloud benchmark (Hetzner Cloud)

For distributed runs (one server node + multiple client nodes), use:

just bench cloud
# or: ./scripts/run_bench_cloud.sh

or invoke the orchestrator directly:

node scripts/cloud_bench_orchestrate.mjs

Prerequisites:

Example:

export HCLOUD_TOKEN=...
just bench cloud-quick
# or: ./scripts/run_bench_cloud.sh --quick

Outputs:

Useful history/render commands:

# List available machines and runs in history
just bench list

# Regenerate chart + README table for a machine
just bench update <machine_id>

# Regenerate from all machines
just bench update all

Current comparison results:

metric parrhesia-pg parrhesia-mem strfry nostr-rs-relay mem/pg strfry/pg nostr-rs-relay/pg
connect avg latency (ms) ↓ 34.67 43.33 2.67 2.67 1.25x 0.08x0.08x
connect max latency (ms) ↓ 61.67 74.67 4.67 4.00 1.21x 0.08x0.06x
echo throughput (TPS) ↑ 72441.00 62704.67 61189.33 152654.33 0.87x 0.84x 2.11x
echo throughput (MiB/s) ↑ 39.67 34.30 34.20 83.63 0.86x 0.86x 2.11x
event throughput (TPS) ↑ 1897.33 1370.00 3426.67 772.67 0.72x 1.81x 0.41x
event throughput (MiB/s) ↑ 1.23 0.87 2.20 0.50 0.70x 1.78x 0.41x
req throughput (TPS) ↑ 13.33 47.00 1811.33 878.33 3.52x135.85x65.88x
req throughput (MiB/s) ↑ 0.03 0.17 11.77 2.40 5.00x353.00x72.00x

Higher is better for metrics. Lower is better for metrics.

(Results from a Linux container on a 6-core Intel i5-8400T with NVMe drive, PostgreSQL 18)

Benchmark chart


Development quality checks

Before opening a PR:

mix precommit

Additional external CLI end-to-end checks with nak:

just e2e nak

For Marmot client end-to-end checks (TypeScript/Node suite using marmot-ts, included in precommit):

just e2e marmot