Skip to content
Open
Changes from 3 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
169 changes: 169 additions & 0 deletions proposals/4308-sliding-sync-ext-thread-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
MSC4308: Thread Subscriptions 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).

Threads Subscriptions are proposed in [MSC4306](https://github.com/matrix-org/matrix-spec-proposals/blob/rei/msc_thread_subscriptions/proposals/4306-thread-subscriptions.md) as a way for users to efficiently indicate which threads they care about, for the purposes of receiving updates.

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.
The `/_matrix/client/v3/sync` endpoint is notorious in real-world applications of Matrix for producing large response bodies (to the amplified detriment of mobile clients) and the high latency caused by waiting for the server to calculate that full payload.

This MSC proposes an 'extension' to Sliding Sync that allows clients to opt-in to receiving real-time updates to the user's thread subscriptions.

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

## Proposal

### Sliding Sync extension

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

```jsonc
{
// ...

"extensions": {
// ...

// Used to opt-in to receiving changes to thread subscriptions.
"thread_subscriptions": {
// Maximum number of thread subscription changes to receive
// in the response.
// Defaults to 100.
// Servers may impose a smaller limit than what is requested here.
"limit": 100,
Copy link
Member

Choose a reason for hiding this comment

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

  • it'd be nice to add the enabled: boolean here too, for consistency with other extensions (and because the synapse impl requires it, and I was confused as to why my request wasn't taken into account xD)
  • also i see in the impl that limit is required, but I wonder if it should? I didn't put it first, assuming the server would give me a nice default 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.

Added the enabled flag, sorry for forgetting it!

The limit is now opened as a bug in Synapse.

}
}
}
```

The response format is then extended to compensate:

```jsonc
{
// ...

"extensions": {
// ...

// Returns a limited window of changes to thread subscriptions
// Only the latest changes are returned in this window.
"thread_subscriptions": {
"changed": {
Copy link
Member

Choose a reason for hiding this comment

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

Out of curiosity, do you have ideas for other sibling fields at this level? Or is this for symmetry with other response subparts?

(The one idea that comes to mind is that the request/response parts could be a subset of a new threads field, something like:

 "threads": {
    "subscriptions": {
      "!roomId1:example.org": { ... }
    }
  }

)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Other than pos which now exists (although might be renamed, c.f. the other thread), the idea was to not block off extensibility; i.e. if we just flattened this struct then we would have no avenue to extend this Sliding Sync extension.

threads -> subscriptions might work, but by the same token, the extension is called threads_subscriptions, so it is weird for the answer to not match the extension name (and I think the Sliding Sync MSC actually says they should match). It would still have the same extensibility problem :/

"!roomId1:example.org": {
// New subscription
"$threadId1": {
"automatic": true
},
// New subscription
// or subscription changed to manual
"$threadId2": {
"automatic": false
},
// Represents a removed subscription
"$threadId3": null
},
"$roomId2:example.org": {
// ...
}
// ...
},

// A token that can be used to backpaginate other thread subscription
// changes that occurred since the last sync, but that were not
// included in this response.
//
// Only present when some thread subscriptions have been missed out
// from the response because there are too many of them.
"gap": "OPAQUE_TOKEN"
Copy link
Contributor

@MadLittleMods MadLittleMods Aug 11, 2025

Choose a reason for hiding this comment

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

Based on prior art, feels like prev_batch/next_batch is the pattern to align with.

Ex.

  • prev_batch from /sync responses are supposed to be used with /messages?from=xxx&to=xxx
  • next_batch from /sync responses are supposed to be used with /sync?since=xxx and /messages?from=xxx&to=xxx
  • pos in Sliding sync responses are supposed to be used with Sliding Sync /sync?since=xxx
  • start/end from /messages responses are supposed to be used with /messages?from=xxx&to=xxx
  • next_batch from /threads responses is supposed to be used with /threads?from=xxx
  • next_token from /notifications responses is supposed to be used with /notifications?from=xxx
  • next_batch from /hierarchy responses is supposed to be used with /hierarchy?from=xxx

Overall, I find our API surface a bit confusing on how we've named fields.

We probably do want to include pagination tokens for both sides of the response (prev/next) so clients can fill in the gap from both directions as desired.

Copy link
Member

Choose a reason for hiding this comment

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

Out of curiosity, what's the use case for a next token? i.e., in which cases is one interested in getting a thread subscriptions and the ones after it? I feel that we're not going to need this in the short term, and, if we need it, we could also add it when it's actually justified by a use case. So, as a wish to keep the API as minimal as it can be, I'd be in favor of not adding support for next as well as a direction parameter, unless it's strongly motivated by a real-world use case.

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'd also rather not add a 'forward' direction. Maybe we can add the dir=b parameter now and require it to always point backwards.

I am struggling to line up the semantics I want here with the existing API patterns, which is namely tracking and filling a gap (i.e. there are bounds on both sides).

The clients should not really care about the direction of filling, because the time of a subscription is not really important, just the latest state of it.

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 have changed the /thread_subscriptions API to use from=xxx&to=xxx&dir=b, but wondering how we should pass back the pair of tokens from Sliding Sync? I've added a new proposal for that but I'm not sure it's the most satisfying (though couldn't see endpoints that do this already for double-ended bounds)

}
}
}
```

If two changes occur to the same subscription, only the latter change ever needs
to be sent to the client. \
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these on purpose? (applies elsewhere)

Suggested change
to be sent to the client. \
to be sent to the client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the most sane of the two ways of adding a line break in the more standard variants of Markdown.
(GFM is I think the only notable dialect that doesn't need this)

Servers do not need to store intermediate subscription states.


### Companion endpoint for backpaginating thread subscription changes

```
GET /_matrix/v1/thread_subscriptions
Copy link
Contributor

@MadLittleMods MadLittleMods Aug 11, 2025

Choose a reason for hiding this comment

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

I love the bulk endpoint 👍 but is there also a way to get thread_subscriptions for a single room?

I can definitely picture some UI which shows the subscribed threads in a room and it would suck to have to wait for the whole room list to paginate in before you could see the list for the single room you're staring at.

The dumb-simple solution is a filter parameter for this endpoint instead of a separate endpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There isn't, but we expect clients to be needing to cache this information locally anyway, so it'd cover a very tiny surface (i.e. during the time we haven't managed to fully sync yet) on a UI I'm not actually sure anyone would implement (rather than 'list of my subscriptions in this room in time order of the subscription', wouldn't you instead be interested in 'list of most recent threads in this room, filtered by whether I'm subscribed').

So I kinda felt it was not worth adding extra API surface here given I don't have a use case in mind for it. If someone disagrees this could easily be rectified in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thinking on this, this also means servers need to add indices for this and optimise a bit for it. It's not really for free. And if we don't think of a valid use for it, I guess that's actually harmful

Copy link
Contributor

Choose a reason for hiding this comment

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

If I come back to my desktop after a week of working on my phone on a holiday, my desktop client is going to need to catch up on the threads I'm subscribed to. If I click into a room and open a threads panel, I will be waiting until my clients catches up on everything whereas it could use the dedicated room endpoint to get everything directly ahead of the entire list on my account.

"filtered by whether I'm subscribed" is the key here. I need to know whether some threads are subscribed.

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 see what you'd mean, I'd kinda like a client dev who is implementing this to provide some kind of opinion though on what would work for them.

An ugly but maybe-valid workaround in that case could be to backpaginate the threads in general and check the subscriptions one-by-one manually. Given HTTP pipelining and multiplexing, there are probably worse crimes.
I think more effective would be to inline the subscription directly in the APIs where that makes sense. e.g. filtering the threads to only subscribed ones, directly in /threads, or at least returning the state there.

Copy link
Member

Choose a reason for hiding this comment

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

If I come back to my desktop after a week of working on my phone on a holiday, my desktop client is going to need to catch up on the threads I'm subscribed to. If I click into a room and open a threads panel, I will be waiting until my clients catches up on everything whereas it could use the dedicated room endpoint to get everything directly ahead of the entire list on my account.

Remember how thread subscriptions are entirely controlled by clients, and never created by the server? If you came back after a holiday and didn't open any client for a week, then you won't have any new subscriptions, by definition. Then, your client would catch up on the threads (not the thread subscriptions), using either automatic back-pagination for all the threads (bleh) or a new threads catchup mechanism (yay), and then only would it recompute new subscriptions for the threads that have been active during your holiday.

If other devices have sync'd and did subscribe you to threads: you'd receive the updates from the sliding sync extension, and then if there were too many of them, you'd use the global endpoints.

Having a room-specific endpoint might likely make the second case above slightly faster (if the other device subscribed to many other threads on many other rooms). But if this endpoint's processing speed is Good Enough™ (viz. not blocked on network/federation), I imagine that it would be sufficient to have only the global feed, for the time being. Does it make sense?

An ugly but maybe-valid workaround in that case could be to backpaginate the threads in general and check the subscriptions one-by-one manually. Given HTTP pipelining and multiplexing, there are probably worse crimes.

For what it's worth, I did experiment with backpaginating all of a room's threads, when implementing a dummy activity center in the SDK, and this was way too slow for a room like MatrixHQ (of course this is the extreme edge case).

I think more effective would be to inline the subscription directly in the APIs where that makes sense. e.g. filtering the threads to only subscribed ones, directly in /threads, or at least returning the state there.

Maybe this could be part of the bundled thread summary, for thread roots, next to the current_user_participated which really thread subscriptions are a generalization of?

Also: note that filtering to only subscribed ones wouldn't give you the information that you may have unsubscribed from another device in the meanwhile.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps the bulk endpoint should work like this:

We could implement a bulk /messages endpoint, where the client would specify multiple rooms and prev_batch tokens.

-- MSC4186

And this also gets around the problem where you can't get to the thread subscriptions that you care about without paginating through the bulk. This way, you can specify the rooms you want to get more thread subscriptions in.

Not sure how fetching thread subscriptions across all rooms would look like yet (many options).

Also being explored in element-hq/synapse#19005 (comment)

```

URL parameters:
Copy link
Contributor

@MadLittleMods MadLittleMods Aug 11, 2025

Choose a reason for hiding this comment

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

Per my other comment,

We probably do want to include pagination tokens for both sides of the response (prev/next) so clients can fill in the gap from both directions as desired.

This would also require a dir direction parameter. I'm not sure it's necessary to refactor to a from/to pattern like /messages but maybe more obvious if they behave the same.

We should also clarify the token bounds. Synapse reference for other places:


- `pos` (string, optional): a token used to continue backpaginating \
The token is either acquired from a previous `/thread_subscriptions` response,
or a 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'.

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

Response body:

```
{
// Required
"chunk": {
"!roomId1:example.org": {
// New subscription
"$threadId1": {
"automatic": true
},
// New subscription
// or subscription changed to manual
"$threadId2": {
"automatic": false
},
// Represents a removed subscription
"$threadId3": null
},
"$roomId2:example.org": {
// ...
}
},
// If there are still more thread subscriptions to fetch,
// a new `pos` token the client can use to walk further
// backwards.
"pos": "OPAQUE_TOKEN"
}
```

If two changes occur to the same subscription, only the latter change ever needs
to be sent to the client. \
Servers do not need to store intermediate subscription states.


## 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.msc4308.thread_subscriptions` instead of `thread_subscriptions`
- the companion endpoint is called `/_matrix/client/unstable/io.element.msc4308/thread_subscriptions` instead of `/_matrix/v1/thread_subscriptions`


## Dependencies

- [MSC4186 Sliding Sync](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sss/proposals/4186-simplified-sliding-sync.md)
- [MSC4306 Threads Subscriptions](https://github.com/matrix-org/matrix-spec-proposals/blob/rei/msc_thread_subscriptions/proposals/4306-thread-subscriptions.md)