Matrix and Synapse
Article.
Verified 2026-10Verified 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:
$create
│
$power ── $alice_join
│ │
$bob_join $msg_1
\ /
$msg_2 prev_events: [$bob_join, $msg_1]prev_eventsare 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_eventsprove 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:
- A content hash: SHA-256 of the canonical JSON of the whole event,
stored in
hashes.sha256. - 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.
- 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.
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:
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 invalidChanging 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:
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 fetcheshttps://example.org/.well-known/matrix/serverfor a delegated host, then tries the_matrix-fed._tcpSRV record, then port 8448. Delegation is how@alice:example.orglives onmatrix.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:
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_statebecomes the largest table on most long-running servers. Thesynapse_auto_compressortool rewrites it room by room; reclaiming the disk then needs aVACUUM 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).
Related
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?” -
/synclong polling with asincetoken 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
/syncto one worker, isolate initial syncs, move federation sending, pushing and media to their own workers.
- Follow-up: how do you scale that in Synapse? Workers sharing PostgreSQL
over Redis; route each user’s
- “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.