Backend / Protocols / Matrix / 01_matrix_and_synapse.md

Matrix and Synapse

Updated 5 interview angles 8 min read source
On this page10
  1. The model: rooms are replicated event graphs
  2. The mechanism: how a server trusts an event it did not create
  3. State resolution: when the graph forks
  4. The client side: /sync
  5. Federation
  6. Synapse in production
  7. Where it breaks in production
  8. The trade not taken: your own chat backend
  9. Related
  10. Interview angle

Matrix and Synapse

Article.

Verified 2026-10

Verified 2026-10-15: Synapse 1.163.0 (released 2026-10-14, Python 3.10+, AGPL, maintained by Element), Matrix spec with room version 12.

Matrix is an open protocol for decentralized chat: anyone can run a homeserver, users on different servers share rooms, and no single server owns a conversation. Synapse is the reference homeserver, written in Python on Twisted.

The question an interviewer is really asking is what a homeserver actually does, and what keeping Synapse fast and correct in production involves. The answer starts with one idea: a room is not a table on a server, it is a replicated graph of signed events.

The model: rooms are replicated event graphs

Identifiers carry their home server: @alice:example.org is a user, !room:example.org a room, $... an event. Every server with a member in a room keeps a full copy of that room.

  • PDUs (persistent data units) are room events: messages and state changes. They form a directed acyclic graph and are stored forever.
  • EDUs (ephemeral data units) are typing notices, read receipts and presence: delivered, never part of the graph.

Each PDU names its parents and its permission:

text
        $create
           │
        $power ── $alice_join
           │           │
     $bob_join     $msg_1
           \         /
            $msg_2        prev_events: [$bob_join, $msg_1]
  • prev_events are the latest events the sending server had seen: the edges of the graph. Two servers sending at once create two branches that a later event merges.
  • auth_events prove the sender was allowed to send it: the create event, the current power levels, the sender’s membership.
  • State events (m.room.member, m.room.power_levels, m.room.join_rules, …) are keyed by (type, state_key); the room’s state at any point is the latest value per key.

The mechanism: how a server trusts an event it did not create

A server receives events from servers it does not control, often relayed through a third. Three pieces of cryptography make that safe, run here with canonicaljson 2.0.0 and signedjson 1.1.4, the libraries Synapse uses:

  1. A content hash: SHA-256 of the canonical JSON of the whole event, stored in hashes.sha256.
  2. The event is redacted (content stripped to the fields the protocol needs) and signed with the origin server’s ed25519 key. The signature covers the redacted form and the content hash.
  3. Since room version 3, the event ID is the reference hash: SHA-256 of the redacted, unsigned event. Nobody chooses an event ID; it is computed.
python
event["hashes"] = {"sha256": content_hash(event)}
event["signatures"] = sign_json(redact(event), "example.org", key)["signatures"]
event_id = "$" + b64url(sha256(canonical_json(strip_signatures(redact(event)))))

A relay tampers with it

Then a relaying server tampers with it:

text
event_id: $zzRm-btffS1Ar8_-I73jUAR9zar1STE4qZ0mbCzKCXA
body changed by a relaying server:
  event_id unchanged: True
  content hash matches: False
  signature still valid
sender changed:
  event_id unchanged: False
  signature invalid

Changing the body leaves the ID and the signature intact, because both cover the redacted form, but the content hash no longer matches, and a receiving server treats the event as redacted: the forged text is never shown.

Changing anything structural changes the event ID and breaks the signature, so the event is rejected. This split is deliberate: a room admin can redact a message everywhere without changing its ID or invalidating the graph built on it.

Servers publish their signing keys at /_matrix/key/v2/server, and every federation request is itself signed (the X-Matrix authorization scheme), so a server knows which server is talking to it.

State resolution: when the graph forks

Two servers can accept conflicting state changes concurrently: one bans Bob while another raises Bob to moderator. When the branches meet, every server must compute the same room state from the same events, without talking to each other.

State resolution is that deterministic algorithm: power-related events (bans, power levels, join rules) are ordered first, by the sender’s power and then a stable tiebreak, and each is re-checked against the auth rules; ordinary state is then applied on top.

The algorithm is fixed by the room version, which is why rooms are upgraded rather than migrated: an m.room.tombstone event closes the old room and points to its replacement.

Room version 12 (Matrix 1.16, September 2025, the “Project Hydra” work) switched to State Resolution v2.1 after a flaw that could reset room state (CVE-2025-49090), made the room ID the hash of the create event, and gave room creators an unlimited power level that power-level events cannot take away.

The client side: /sync

Clients talk to their own homeserver only. New events arrive through long polling:

http
GET /_matrix/client/v3/sync?since=s72594_4483_1934&timeout=30000
Authorization: Bearer <access token>

The server holds the request until something happens or 30 seconds pass, then returns new events and a next_batch token for the following call. The first sync after login, with no since, returns the whole account state and is the most expensive request a homeserver serves. Element X uses Simplified Sliding Sync (MSC4186), which fetches only the visible window of rooms; Synapse has implemented it natively, on by default, since 1.114.

Federation

  • Discovery. To reach example.org, a server fetches https://example.org/.well-known/matrix/server for a delegated host, then tries the _matrix-fed._tcp SRV record, then port 8448. Delegation is how @alice:example.org lives on matrix.example.org.
  • Sending. Events go out in PUT /send/{txnId} transactions of at most 50 PDUs and 100 EDUs, retried until the remote answers 200.
  • Catching up. A server missing parents asks for them (/get_missing_events) and fetches history with /backfill.
  • Joining. Joining a large federated room means receiving and checking its full state, every membership event included: minutes of CPU for a room with tens of thousands of members.

Synapse in production

Synapse runs on PostgreSQL (SQLite only for testing) and scales by splitting into workers that share the database and talk over Redis pub/sub:

Worker Takes over
generic_worker client and federation reads, /sync
event persisters writing events, shardable by room
stream writers typing, to-device, receipts, presence
federation senders outbound federation traffic
pushers sending push notifications
media repository uploads, downloads, thumbnails

Nginx in front routes by path, and for /sync by user, because a user’s syncs should land on one worker to use its caches. Synapse’s docs also suggest sending initial syncs (no since) to separate workers so that logins do not slow everyone’s incremental syncs:

nginx
map $arg_since $sync_pool {
    ""      initial_sync;              # no since: expensive, isolate it
    default incremental_sync;
}

location ~ ^/_matrix/client/v3/sync$ {
    proxy_pass http://$sync_pool;      # upstreams hash on the access token
    proxy_read_timeout 90s;            # longer than the 30 s long poll
    proxy_set_header X-Forwarded-For $remote_addr;
}

location /_matrix {
    proxy_pass http://synapse_main;
    client_max_body_size 100M;         # media uploads
}

The replication listener must never be reachable from the internet: its traffic is unauthenticated unless worker_replication_secret is set.

Where it breaks in production

  • The state tables grow without bound. Room state is stored as state groups, and state_groups_state becomes the largest table on most long-running servers. The synapse_auto_compressor tool rewrites it room by room; reclaiming the disk then needs a VACUUM FULL.
  • Someone joins a giant public room. One user’s join pulls in the full state of a 50,000-member room and the server spends minutes on it. Synapse can refuse rooms above a complexity limit (limit_remote_rooms).
  • Initial syncs after an outage. Every client reconnecting at once with a stale or missing token is a wave of the most expensive request; isolate initial syncs and scale those workers first.
  • Remote media fills the disk. Every image seen in a federated room is cached locally; purge remote media older than a retention window.
  • The signing key is lost. The server’s identity in federation is its signing key; losing it without publishing the old key breaks verification of its past events. Back it up like a CA key.
  • A dead remote server. Synapse backs off from unreachable servers, and outbound queues to them grow; one large room with many dead servers costs federation sender time.

The trade not taken: your own chat backend

The alternative is a chat service of your own: WebSockets, a messages table, your own push. For a single product with no federation it is far simpler, and it is what most companies build.

Matrix is the choice when the requirements include what it gives for free: federation between organisations, end-to-end encryption with Olm and Megolm already specified, existing clients on every platform, bridges to other networks, and data sovereignty for customers who run their own server.

The cost is the graph: state resolution, a database that grows with history, and a homeserver that is CPU- and database-heavy compared with a plain message store.

End-to-end encryption shapes the rest of the stack: in an encrypted room the homeserver cannot read message content, which is why push notifications usually carry only an event ID (Sygnal: the Matrix push gateway).

Interview angle 5

  • “What is Matrix, in one sentence a backend engineer would use?” - an open protocol where each room is a graph of signed events replicated to every server with a member in it, so no server owns the conversation.
    • Follow-up: how does a server trust an event relayed by another server? The event ID is the hash of the redacted event, the origin server signs the redacted form plus a content hash, and receivers check both. A relay that rewrote the body left the ID and signature valid but failed the content hash, so the event is shown as redacted; changing the sender broke the signature.
  • “Two servers ban and promote the same user at the same time. Who wins?”
    • state resolution: a deterministic algorithm, fixed by the room version, that every server runs on the same events to reach the same state. Room version 12 moved to State Resolution v2.1 after a state-reset flaw.
  • “How do clients receive messages?” - /sync long polling with a since token and a 30-second timeout, or Simplified Sliding Sync for Element X. Initial syncs are the expensive requests.
    • Follow-up: how do you scale that in Synapse? Workers sharing PostgreSQL over Redis; route each user’s /sync to one worker, isolate initial syncs, move federation sending, pushing and media to their own workers.
  • “What grows on a long-running Synapse?” - state_groups_state, remote media and the event tables. Compress state groups and purge remote media.
  • “Would you use Matrix for a company’s internal chat feature?” - only if federation, E2EE or self-hosting by customers matters; otherwise a plain chat service is simpler and cheaper to run.