Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 286 additions & 0 deletions proposals/4360-sliding-sync-extension-threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
MSC4360: Threads extension to Sliding Sync
===

## Background and Summary

Threads were introduced in [version 1.4 of the Matrix Specification](https://spec.matrix.org/v1.13/changelog/v1.4/) as a way to isolate conversations in a room, making it easier for users to track specific conversations that they care about (and ignore those that they do not).

Sliding Sync is proposed in [MSC4186](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sss/proposals/4186-simplified-sliding-sync.md) as a paginated replacement to `/_matrix/client/v3/sync` with smaller response bodies and lower latency.

It is currently a hassle, or nearly impossible, to be able to determine which threads in a user's joined rooms contain
updates. This is especially true when a client returns after having been offline for a period of time. The full set of
events in the timeline has to be paginated through to attempt discovery of any thread events that may have been missed.
This is both cumbersome for the client and slow.

This MSC proposes an 'extension' to Sliding Sync that allows clients to opt-in to receiving real-time updates to threads
in the user's joined rooms. The new `extension` provides a mechanism for clients to quickly and easily "catch up" to any
missed thread updates.
Comment on lines +16 to +17
Copy link
Contributor

@MadLittleMods MadLittleMods Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a similar regard to "what is a thread update", how do we expect people to get thread activity for historical rooms (they were previously joined and are no longer joined)? They should be able to just as easily spider out their interested threads.

This includes the case where they are newly_left within the batch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if clients would care about getting "updates" for rooms they are no longer following.
The intent of the new API is to allow clients to follow along with new updates that are relevant. This is especially evident with the API design of only returning (room_id, thread_id) pairs in the response to indicate that a particular thread had at least one update in that window. The APIs are not meant to be able to query all thread events.

If a client wants historical thread events, they should use the /relations endpoint to retrieve them.

Copy link
Contributor

@MadLittleMods MadLittleMods Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you're left, using /threads makes sense to find threads in the room and GET /relations/{threadRootId}/m.thread to find all events in the thread.

I think this just needs to be clarified that you should still get updates if you have a Sliding Sync pos token where you are joined to the room, some thread updates occur, you leave, and then you do another incremental sync (newly_left). You should get thread updates until you left.


To handle the case in which there have been many thread updates and there are too many to return in
Sliding Sync, a new companion endpoint is proposed to allow backpaginating thread updates across all
of the user's joined rooms on the client's terms.


## Proposal

The `threads` Sliding Sync extension adds additional output to the `/sync` response as well as a new `/thread_updates` companion endpoint
that can be used to paginate thread updates across all of a user's joined rooms. A client's homeserver will track which threads
have had updates since the last time `/sync` was called and present a list of threads containing updates to
the client the next time `/sync` is called.
The presented list of threads containing updates can cover a larger portion of the timeline than
just the normal sync response would be able to handle, thus allowing clients to quickly gather information on which
threads have updates without having to paginate through the entire timeline of events.


### Sliding Sync extension
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm failing to see the value in the Sliding Sync extension.

It seems like someone could just paginate GET /_matrix/client/v1/thread_updates directly to get the same value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They would need to continually query /thread_updates in that case to get the same behaviour of not missing new thread updates, effectively running an additional but separate sync-style loop on /thread_updates that is specific to obtaining thread updates.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is how I'm thinking about it right now: /sync provides all messages as they come in. If there is a gap (limited: true), then people can use /thread_updates.

Am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the scenario of limited: true from the sliding sync response, this MSC would allow for any additional thread events that weren't included in the regular sync response to be included in the threads extension portion.
So you save yourself needing to go and do the extra query to /thread_updates (unless you are also limited on thread updates as well in the extension response)

ie.
Sync response covers events A-Z
Sync response only returns events A-M
The threads extension will include any threads that have updates caused by events N-Z.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So yes - you could just go and hit the new endpoint.
But this allows you not to need to do that, and to just receive those updates directly in the sync response you already have.

Copy link
Contributor

@MadLittleMods MadLittleMods Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reminds me of my suggestion for sticky events. In both of these cases, we are trying to tackle the same problem of gathering a subset of the events in the timeline:

I think the better way to go about is to apply the same pattern that we just worked through with thread subscriptions. We would need a few things:

  • A way to return the new sticky events in /sync
  • Dedicated sticky_events_limited flag when there are other sticky events that we didn't return
  • A pagination endpoint for the sticky events

To be more detailed, these could be normal timeline events. If there are more sticky events in between the given sync token and the current position, we set the sticky_events_limited flag. For the pagination endpoint, we could overload /messages with a new filter.

-- @MadLittleMods, internal room

With some more thinking on the subject, the dedicated Sliding Sync extension worked well for thread_subscriptions because thread_subscriptions aren't part of the response already.

Whereas with sticky events and thread updates, they are already part of the timeline.

To break down the list of things needed (as listed above) and my current thinking:

"A way to return the new sticky events in /sync"

In the case of threads and sticky events, these events are already included as part of the timeline and /sync already keeps you up to date with all of the events.

"A pagination endpoint for the sticky events"

The dedicated /threads_update makes sense to back-paginate the gaps (whenever the timeline is limited: true)

"Dedicated sticky_events_limited flag when there are other sticky events that we didn't return"

With more thinking, I think this one is optional.

We could have an extension that indicates whether thread_updates_limited but this only saves a few extra requests in the cases where the timeline is limited: true but there weren't actually any new thread updates in the gap.


Having another field in the Sliding Sync extension to include more thread updates in initial and gappy syncs isn't that useful (and makes things more complicated). You can just call /thread_updates

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bnjbvr Would you be able to comment here on your experience with client-side thread syncing and whether what @MadLittleMods is suggesting would be better or worse?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarify what this includes in the case of initial sync


The new Sliding Sync `threads` extension is an optional addition that provides a list of updated threads to a client.
If the client hasn't missed any thread updates, then this whole section of the response is omitted.

When generating the content of the `threads` extension response, if a particular thread contains more than one
update for the client, a `prev_batch` token is provided for that thread. This `prev_batch` token can be used
with the `/relations` endpoint as the `from` parameter, with `dir`=`b`, to obtain other missed updates in that thread.

If there are more threads containing updates than can be included in the response, a `prev_batch` token is provided
with the list of threads. This `prev_batch` token can be used with the new `/thread_updates` companion endpoint as the
`from` parameter, with `dir`=`b`, to obtain other missed thread updates across all of the user's joined rooms.

There are a number of cases to consider when generating the `threads` extension response to a `/sync`:


##### `include_roots` is set to `false`

When `include_roots` is `false` the `thread_root` fields are always omitted from the thread updates.
If a client receives the event/s in the normal response section of `/sync` that would result
in a thread being considered updated, then that thread is omitted from list of updated threads in the extension response.
> ie. When `include_roots` is `false`, only threads with updates that haven't otherwise been presented to
the client via the normal `/sync` response are included in the extension response.

Under normal client operation where a client is online and continually syncing, this has the desirable
effect of making the `threads` extension zero overhead for the client. This assumes that the client is
receiving small, untruncated, batches of new events down `/sync` such that any event/s which would result
in a thread being considered updated are already being passed down to the client and can be omitted from the
`threads` extension. It is only in the case of events being omitted from the normal `/sync` response or a
client falling behind that updates would be included in the `threads` extension.


##### `include_roots` is set to `true`

When `include_roots` is `true` the `thread_root` fields are always included in the thread updates, and thread
updates that would have been otherwise omitted are included in the extension response.
This is true even in the case where the thread update event/s are included in the normal response section
of `/sync`. This may result in some amount of duplicate data in the `/sync` response since the `thread_root`
event contains a copy of the `latest_event` of the thread in it's `unsigned` fields.

Setting `include_roots` to `true` can be useful to ensure thread root changes, such as edits to the thread root, are captured
and passed down in a useful way to the client. The thread root events also include a copy of the latest event in that
thread to make it extremely easy for a client to present a view of threads, whether there are updates, and a preview of
the latest content in each thread.


#### Extension Format

The Sliding Sync request format is extended to include the `threads` extension as follows:

```jsonc
{
// ...

"extensions": {
// ...

// Used to opt-in to receiving changes to threads in joined rooms.
"threads": {
// Whether to enable this extension.
"enabled": true,

// Whether to include thread root events in the extension response.
"include_roots": true | false,

// Optional. Maximum number of thread updates to receive
// in the response.
// Defaults to 100.
// Servers may impose a smaller limit than what is requested here.
"limit": 100,
}
}
}
```

The response format is then extended to compensate:

```jsonc
{
// ...

"extensions": {
// ...

// Returns a limited window of changes to updated threads.
// Only the latest changes are returned in this window.
// If the client hasn't missed anything, then this whole section of the response is omitted.
"threads": {
"updates": {
"!roomid:example.org": {
"$threadrootid:example.org": {
// Only included if the request contains `include_roots: true`.
// A `BundledThreadEvent` (as outlined in https://spec.matrix.org/v1.15/client-server-api/#server-side-aggregation-of-mthread-relationships)
// is a thread root event which contains the `m.thread` aggregation included under the
// `m.relations` property in the `unsigned` field of the event.
"thread_root": BundledThreadEvent,

// A token that can be used to backpaginate other thread updates,
// in this thread, that occurred since the last sync but that were not
// included in this response.
//
// The token is to be used with the `/relations` endpoint
// as `from`, with `dir`=`b`.
//
// Optional. Only present in the response if the client missed some events, i.e. there
// was at least one other event in the thread, in addition to the latest event.
// In other words, the `prev_batch` points to the prior-to-latest event.
"prev_batch": "OPAQUE_TOKEN",
},

// ...
}
},

// A token that can be used to backpaginate other thread updates
// that occurred in any thread, in any room, since the last sync but that were not
// included in this response.
//
// The token is to be used with the new `/thread_updates` endpoint
// as `from`, with `dir`=`b`.
// The `pos` parameter in the **request** would be used for the `to`
// parameter.
//
// Optional. Only present when some thread updates have been
// missed out from the response because there are too many of them.
"prev_batch": "OPAQUE_TOKEN"
}
}
}
```

### Companion endpoint for backpaginating thread updates across all rooms

A new `/thread_updates` endpoint is added to allow a client to obtain missing thread updates for a client.
Copy link
Contributor

@MadLittleMods MadLittleMods Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a thread update?

Does it include edits to messages in the thread? Edits to the thread root (and maybe the latest message in thread) are mentioned in the MSC but it's unclear about messages in the middle. Especially in the case where your client has already paginated the thread, a period of time passes leaving a gap with some edits to the thread messages and you /sync or use this endpoint again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thread update is simply any event which has a m.thread relation. This doesn't currently include edits to any of the thread events. I'm not sure if edits would be particularly useful to clients, whereas new events in a given thread is a clear and meaningful change to a thread that a user would want to be notified about.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we expect a client to show the most recent view of a thread (with edits and other bundled aggregations)?

Imagine the scenario where you have kept up to date with all of the messages in a thread, then you go offline, some edits and reactions occur to the thread messages, more unrelated activity occurs to fill up the /sync response which leaves the edits/reactions in the gap and not included in the timeline when you come back online and /sync again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the best approach should be. I can see an argument where clients may or may not want to include events and/or annotations as "thread updates".
We could extend the MSC to include edits & annotations as "thread updates", but if we did that we may want to extend the extension config to make them optional, or provide a better way for clients to define what they want to be considered as a "thread update".

@bnjbvr Can you weigh in here on what clients would want?

The new endpoint operates as a bulk fetch endpoint, operating across all of a user's joined rooms, allowing
a client to obtain only relevant information with minimal amounts of network requests.
There is an existing `/threads` endpoint, but it returns all thread roots for a room, not just threads
which contain updates relevant for a client. The existing endpoint also operates on a per-room basis which
means a client would need to perform at least one network request per-room that the user is joined to.

```
GET /_matrix/client/v1/thread_updates
```

URL parameters:

- `dir` (string, required): always `b` (backward), to mirror other pagination
endpoints. The forward direction is not yet specified to be implemented.

- `from` (string, optional): a token used to continue backpaginating \
The token is either acquired from a previous `/thread_updates` response,
or the `prev_batch` in a Sliding Sync response. \
Comment on lines +185 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarify part of the thread extension in the Sliding Sync response

The token is opaque and has no client-discernible meaning. \
If this token is not provided, then backpagination starts from the 'end'.

- `to` (string, optional): a token used to limit the backpagination \
The token can be acquired from a Sliding Sync response.

- `limit` (int, optional; default `100`): a maximum number of thread updates to fetch
in one response. \
Must be greater than zero. Servers may impose a smaller limit than requested.


Response body:

```jsonc
{
"chunk": {
"!roomid:example.org": {
"$threadrootid:example.org": {
// A `BundledThreadEvent` (as outlined in https://spec.matrix.org/v1.15/client-server-api/#server-side-aggregation-of-mthread-relationships)
// is a thread root event which contains the `m.thread` aggregation included under the
// `m.relations` property in the `unsigned` field of the event.
"thread_root": BundledThreadEvent,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be optional similar to the new sliding sync extension response? Or even removed from this response altogether?


// A token that can be used to backpaginate other thread updates,
// in this thread, that occurred since the last sync but that were not
// included in this response.
//
// The token is to be used with the `/relations` endpoint
// as `from`, with `dir`=`b`.
//
// Optional. Only present in the response if the client missed some events, i.e. there
// was at least one other event in the thread, in addition to the latest event.
// In other words, the `prev_batch` points to the prior-to-latest event.
"prev_batch": "OPAQUE_TOKEN",
Comment on lines +217 to +220
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this should be present no matter what. A server could come online and have some messages in between which we then later want to paginate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm not sure I see the use case you are talking about. Can you spell out the specifics of when this might occur?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think was thinking of the backfill scenario. But I guess backfilled messages wouldn't be considered updates since they are historical ⏩

And for any new messages since a server joined, the server should be doing /send transactions so those messages will show up as thread updates.


The following is a general existing problem as far as I can tell but perhaps would be good more my understanding if I had an explanation here (segregated networks). I'm a bit confused on what would happen if you had servers A, B, C where B and C can't talk to each other directly. A new message from B will flow to A normally via /send transactions. And then the only way I could see the new message flowing from B to C is if C backfilled from A. But C has no reason to backfill so it probably won't see any of those messages 🤔. Is that expected?

And even if it did backfill, they are backfilled messages so they would never been seen as thread updates. And I don't see how the client would know to re-paginate anything to show the full view of the thread. There weren't even any gaps when the client originally paginated the thread so indicating gaps in the timeline wouldn't help either.

flowchart TD
    A <--> B
    A <--> C
Loading

Perhaps an even simpler scenario is if a bunch of activity occurred on one server and then it tries to /send it to the other server but it's is offline (or receives a 429 rate-limited response by the other server) and backs-off and gives up. In this case, we have a mechanism in Synapse to try to /send again later (wake-up destinations that need catchup).

},

// ...
}
},

// A token to supply to `from` to keep paginating the responses. Not present when there are no further results.
"next_batch": "OPAQUE_TOKEN"
}
```

No matter how many events were missed in a thread, only one update must be sent to the client per-thread.
If the client is interested in exploring the other missed thread updates further, the `/relations` endpoint
should be used to paginate the events.

The pagination structure of this endpoint matches that of the `/threads` endpoint with the addition of a `to`
parameter to be able to further limit the the scope of the response.


## Expected client behavior

The following outlines how a client would be expected to utilize this new Sliding Sync extension.

When restarting a device, `/sync` would be called with the threads extension enabled and the `include_roots` parameter set to true.
The homeserver would respond to the `/sync` request and give a list of all the threads that have been updated since the last time the client performed a `/sync`.
The homeserver responds with a list of N updated threads and a `prev_batch` token if there were any thread updates
omitted from the list. If there were thread updates omitted, the client keeps on paginating `/thread_updates` with the
`from={prev_batch}&to={pos}` (similar to the usage in [MSC4308](https://github.com/matrix-org/matrix-spec-proposals/pull/4308) until it
exhausts the list of thread updates since the previous time, ie. when pagination has reached `pos`).
The client then sets `include_roots` to `false` to limit the amount of duplicate data being sent down `/sync`.
Further updates to threads will come down the normal `/sync` response, and not be included in the `threads` extension
unless there are too many events in the normal `/sync` response, in which case, any thread update events not included
in the normal `/sync` response will be included in the `threads` extension

If a client is actively in some sort of thread activity monitoring view, a client should set `include_roots` to true to capture the case
of the thread roots having been edited and the client not receiving the edit event (because of timeline gaps).
The edit information (and other aggregations) would be available from the bundled aggregations section in the thread root event provided
in the `threads` extension response.


## Potential issues


## Alternatives


## Limitations


## Security considerations

- No particular security issues anticipated.


## Unstable prefix

Whilst this proposal is unstable, a few unstable prefixes must be observed by experimental implementations:

- the Sliding Sync extension is called `io.element.msc4360.threads` instead of `threads`
- the companion endpoint is called `/_matrix/client/unstable/io.element.msc4360/thread_updates` instead of `/_matrix/client/v1/thread_updates`


## Dependencies

- [MSC4186 Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sss/proposals/4186-simplified-sliding-sync.md)