-
Notifications
You must be signed in to change notification settings - Fork 318
feat(realtime): introduce message-based protocol for subscriptions #2934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
atharvadeosthale
merged 7 commits into
main
from
feat/realtime-message-based-sdk-announcement
Apr 29, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
cf0e7ad
feat(realtime): introduce message-based protocol for subscriptions
eldadfux e749dac
Merge origin/main into feat/realtime-message-based-sdk-announcement
eldadfux e4cf7ef
feat: update cover image for the "One WebSocket, many subscriptions" …
eldadfux f9cf07f
chore(blog): update Realtime announcement cover image
eldadfux 91eab55
improvements to realtime docs + announcement
atharvadeosthale da21702
merge conflicts
atharvadeosthale 4b56156
optimize
atharvadeosthale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
311 changes: 311 additions & 0 deletions
311
src/routes/blog/post/announcing-message-based-realtime-sdk/+page.markdoc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| --- | ||
| layout: post | ||
| title: "One WebSocket, many subscriptions: smarter Realtime in Appwrite" | ||
| description: "Appwrite Realtime now keeps one persistent WebSocket and drives subscriptions with messages instead of cramming state into the connection URL. Client SDKs expose unsubscribe(), update(), and disconnect() for clearer lifecycle control." | ||
| date: 2026-04-29 | ||
| cover: /images/blog/announcing-message-based-realtime-sdk/cover.jpg | ||
| timeToRead: 9 | ||
| author: eldad-fux | ||
| category: announcement | ||
| featured: false | ||
| --- | ||
|
|
||
| Realtime features are where users feel your app is “alive”: collaborative edits, live dashboards, and instant feedback when data changes. That experience depends on how predictable your subscription lifecycle is. If every tweak to what you listen for forces a full reconnect, you pay in latency, battery, and mental overhead. | ||
|
|
||
| **Appwrite Realtime** now uses a **message-based protocol** on a **single persistent WebSocket**. The service applies subscription changes incrementally over the socket instead of treating the WebSocket URL as the source of truth for what you listen to. | ||
|
|
||
| Previously, subscription details were largely carried in the **query string of the Realtime WebSocket URL**. That tied you to **URL length limits** enforced by browsers, servers, and proxies, so in practice you could only combine so many channels and so much metadata before the connection string itself became a bottleneck. | ||
|
|
||
| That friction grew once we shipped [Realtime queries](/blog/post/announcing-realtime-queries) to filter subscription events on the server, and larger query payloads made the URL ceiling easier to hit. Channels and queries are now sent **over the established socket**, so you are not capped by query-string size when scaling up listeners or filters. | ||
|
|
||
| # One connection, many subscriptions | ||
|
|
||
| You create a `Realtime` instance from your configured `Client`, then call `subscribe` for each logical listener. The example below subscribes to two channels (`files` and `account`) on a single connection, shown across Appwrite clients in the tabs below. | ||
|
|
||
| {% multicode %} | ||
| ```client-web | ||
| import { Client, Realtime, Channel } from "appwrite"; | ||
|
|
||
| const client = new Client() | ||
| .setEndpoint('https://<REGION>.cloud.appwrite.io/v1') | ||
| .setProject('<PROJECT_ID>'); | ||
|
|
||
| const realtime = new Realtime(client); | ||
|
|
||
| const sub1 = await realtime.subscribe(Channel.files(), response => { | ||
| console.log(response); | ||
| }); | ||
|
|
||
| const sub2 = await realtime.subscribe(Channel.account(), response => { | ||
| console.log(response); | ||
| }); | ||
| ``` | ||
|
|
||
| ```client-flutter | ||
| import 'package:appwrite/appwrite.dart'; | ||
|
|
||
| final client = Client() | ||
| .setEndpoint('https://<REGION>.cloud.appwrite.io/v1') | ||
| .setProject('<PROJECT_ID>'); | ||
|
|
||
| final realtime = Realtime(client); | ||
|
|
||
| final sub1 = realtime.subscribe([Channel.files()]); | ||
| final sub2 = realtime.subscribe([Channel.account()]); | ||
|
|
||
| sub1.stream.listen((response) { | ||
| print(response); | ||
| }); | ||
|
|
||
| sub2.stream.listen((response) { | ||
| print(response); | ||
| }); | ||
| ``` | ||
|
|
||
| ```client-apple | ||
| import Appwrite | ||
| import AppwriteModels | ||
|
|
||
| let client = Client() | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>") | ||
|
|
||
| let realtime = Realtime(client) | ||
|
|
||
| let sub1 = realtime.subscribe(channels: [Channel.files()]) { response in | ||
| print(String(describing: response)) | ||
| } | ||
|
|
||
| let sub2 = realtime.subscribe(channels: [Channel.account()]) { response in | ||
| print(String(describing: response)) | ||
| } | ||
| ``` | ||
|
|
||
| ```client-android-kotlin | ||
| import io.appwrite.Channel | ||
| import io.appwrite.Client | ||
| import io.appwrite.services.Realtime | ||
|
|
||
| val client = Client(context) | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>") | ||
|
|
||
| val realtime = Realtime(client) | ||
|
|
||
| val sub1 = realtime.subscribe(Channel.files()) { | ||
| print(it.payload.toString()) | ||
| } | ||
|
|
||
| val sub2 = realtime.subscribe(Channel.account()) { | ||
| print(it.payload.toString()) | ||
| } | ||
| ``` | ||
|
|
||
| ```client-android-java | ||
| import io.appwrite.Client; | ||
| import io.appwrite.models.RealtimeResponseEvent; | ||
| import io.appwrite.models.RealtimeSubscription; | ||
| import io.appwrite.services.Realtime; | ||
| import kotlin.Unit; | ||
|
|
||
| Client client = new Client(context) | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>"); | ||
|
|
||
| Realtime realtime = new Realtime(client); | ||
|
|
||
| RealtimeSubscription sub1 = realtime.subscribe( | ||
| new String[] {"files"}, | ||
| (RealtimeResponseEvent<Object> response) -> { | ||
| System.out.println("files " + response); | ||
| return Unit.INSTANCE; | ||
| } | ||
| ); | ||
|
|
||
| RealtimeSubscription sub2 = realtime.subscribe( | ||
| new String[] {"account"}, | ||
| (RealtimeResponseEvent<Object> response) -> { | ||
| System.out.println("account " + response); | ||
| return Unit.INSTANCE; | ||
| } | ||
| ); | ||
| ``` | ||
|
|
||
| {% /multicode %} | ||
|
|
||
| Unsubscribing one handle **does not** drop unrelated listeners: the Realtime service keeps the shared connection and removes only what you asked for. | ||
|
|
||
| Call `unsubscribe()` on a subscription handle to stop that listener, and `realtime.disconnect()` to close the socket entirely. The legacy `close()` alias remains for backwards compatibility. See the [subscribe documentation](/docs/apis/realtime/subscribe) for the full API. | ||
|
|
||
| {% multicode %} | ||
| ```client-web | ||
| await sub1.unsubscribe(); // only sub1 stops receiving events | ||
| await sub2.unsubscribe(); // only sub2 | ||
|
|
||
| // When your UI is done with Realtime (for example on unmount): | ||
| realtime.disconnect(); | ||
| ``` | ||
|
|
||
| ```client-flutter | ||
| await sub1.unsubscribe(); | ||
| await sub2.unsubscribe(); | ||
|
|
||
| await realtime.disconnect(); | ||
| ``` | ||
|
|
||
| ```client-apple | ||
| try await sub1.unsubscribe() | ||
| try await sub2.unsubscribe() | ||
|
|
||
| try await realtime.disconnect() | ||
| ``` | ||
|
|
||
| ```client-android-kotlin | ||
| sub1.unsubscribe() | ||
| sub2.unsubscribe() | ||
|
|
||
| realtime.disconnect() | ||
| ``` | ||
|
|
||
| ```client-android-java | ||
| sub1.unsubscribe(); | ||
| sub2.unsubscribe(); | ||
|
|
||
| realtime.disconnect(); | ||
| ``` | ||
|
|
||
| {% /multicode %} | ||
|
|
||
| # In-place subscription updates | ||
|
|
||
| Changing channels or queries no longer requires recreating the subscription. Call **`update()`** on the subscription handle to adjust the channels or queries while the WebSocket stays open. The API is available across **Web, Flutter, Apple, and Android** SDKs (see [Subscribe](/docs/apis/realtime/subscribe#update-a-subscription)). | ||
|
|
||
| {% multicode %} | ||
| ```client-web | ||
| import { Client, Realtime, Channel, Query } from "appwrite"; | ||
|
|
||
| const client = new Client() | ||
| .setEndpoint('https://<REGION>.cloud.appwrite.io/v1') | ||
| .setProject('<PROJECT_ID>'); | ||
|
|
||
| const realtime = new Realtime(client); | ||
|
|
||
| const subscription = await realtime.subscribe(Channel.files(), response => { | ||
| console.log(response); | ||
| }); | ||
|
|
||
| await subscription.update({ | ||
| channels: [Channel.tablesdb('<DATABASE_ID>').table('<TABLE_ID>').row('<ROW_ID>')], | ||
| queries: [Query.equal('title', ['todo'])], | ||
| }); | ||
| ``` | ||
|
|
||
| ```client-flutter | ||
| import 'package:appwrite/appwrite.dart'; | ||
|
|
||
| final client = Client() | ||
| .setEndpoint('https://<REGION>.cloud.appwrite.io/v1') | ||
| .setProject('<PROJECT_ID>'); | ||
|
|
||
| final realtime = Realtime(client); | ||
|
|
||
| final subscription = realtime.subscribe([Channel.files()]); | ||
|
|
||
| subscription.stream.listen((response) { | ||
| print(response); | ||
| }); | ||
|
|
||
| await subscription.update( | ||
| channels: [Channel.tablesdb('<DATABASE_ID>').table('<TABLE_ID>').row('<ROW_ID>')], | ||
| queries: [Query.equal('title', ['todo'])], | ||
| ); | ||
| ``` | ||
|
|
||
| ```client-apple | ||
| import Appwrite | ||
| import AppwriteModels | ||
|
|
||
| let client = Client() | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>") | ||
|
|
||
| let realtime = Realtime(client) | ||
|
|
||
| let subscription = realtime.subscribe(channels: [Channel.files()]) { response in | ||
| print(String(describing: response)) | ||
| } | ||
|
|
||
| try await subscription.update(RealtimeSubscriptionUpdate( | ||
| channels: [Channel.tablesdb("<DATABASE_ID>").table("<TABLE_ID>").row("<ROW_ID>")], | ||
| queries: [Query.equal("title", value: ["todo"])] | ||
| )) | ||
| ``` | ||
|
|
||
| ```client-android-kotlin | ||
| import io.appwrite.Channel | ||
| import io.appwrite.Client | ||
| import io.appwrite.Query | ||
| import io.appwrite.services.Realtime | ||
| import io.appwrite.models.RealtimeSubscriptionUpdate | ||
|
|
||
| val client = Client(context) | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>") | ||
|
|
||
| val realtime = Realtime(client) | ||
|
|
||
| val subscription = realtime.subscribe(Channel.files()) { | ||
| print(it.payload.toString()) | ||
| } | ||
|
|
||
| subscription.update(RealtimeSubscriptionUpdate( | ||
| channels = listOf(Channel.tablesdb("<DATABASE_ID>").table("<TABLE_ID>").row("<ROW_ID>")), | ||
| queries = listOf(Query.equal("title", listOf("todo"))), | ||
| )) | ||
| ``` | ||
|
|
||
| ```client-android-java | ||
| import io.appwrite.Client; | ||
| import io.appwrite.Query; | ||
| import io.appwrite.models.RealtimeResponseEvent; | ||
| import io.appwrite.models.RealtimeSubscription; | ||
| import io.appwrite.models.RealtimeSubscriptionUpdate; | ||
| import io.appwrite.services.Realtime; | ||
| import kotlin.Unit; | ||
|
|
||
| import java.util.Arrays; | ||
|
|
||
| Client client = new Client(context) | ||
| .setEndpoint("https://<REGION>.cloud.appwrite.io/v1") | ||
| .setProject("<PROJECT_ID>"); | ||
|
|
||
| Realtime realtime = new Realtime(client); | ||
|
|
||
| RealtimeSubscription subscription = realtime.subscribe( | ||
| new String[] {"files"}, | ||
| (RealtimeResponseEvent<Object> response) -> { | ||
| System.out.println(response); | ||
| return Unit.INSTANCE; | ||
| } | ||
| ); | ||
|
|
||
| subscription.update(new RealtimeSubscriptionUpdate( | ||
| Arrays.asList("tablesdb.<DATABASE_ID>.tables.<TABLE_ID>.rows.<ROW_ID>"), | ||
| Arrays.asList(Query.equal("title", Arrays.asList("todo"))) | ||
| )); | ||
| ``` | ||
|
|
||
| {% /multicode %} | ||
|
|
||
| # Why this matters | ||
|
|
||
| - **Clearer ownership**: Each subscription is its own handle with a predictable lifecycle. | ||
| - **Better performance**: Fewer full reconnects when your app state shifts. | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| - **Simpler UI code**: Mount paths call `subscribe` (or `update`), unmount paths call `unsubscribe` or `disconnect`. | ||
|
|
||
| # More resources | ||
|
|
||
| - [Introducing Realtime queries: Server-side event filtering for subscriptions](/blog/post/announcing-realtime-queries) | ||
| - [Realtime API overview](/docs/apis/realtime) | ||
| - [Subscribe and manage channels](/docs/apis/realtime/subscribe) | ||
| - [Realtime channels and helpers](/docs/apis/realtime/channels) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| --- | ||
| layout: changelog | ||
| title: "Realtime: persistent WebSocket and message-driven subscriptions" | ||
| date: 2026-04-29 | ||
| --- | ||
|
|
||
| Appwrite [**Realtime**](/docs/apis/realtime) now keeps a **single WebSocket** per client session and applies **incremental, message-based** subscription changes instead of encoding subscription state mainly in the connection URL and reconnecting for every add or remove. | ||
|
|
||
| Previously, subscription details were often encoded in the **WebSocket URL query string**. That tied you to **URL length limits** in browsers, proxies, and servers, which capped how many [channels](/docs/apis/realtime/channels) you could combine and how much metadata you could send on one connection. It also squeezed [Realtime queries](/blog/post/announcing-realtime-queries) for server-side filtering, because every extra filter still had to fit in the same limited URL. Channels and queries are now carried **over the open socket**, so you are not constrained by query-string size the same way. | ||
|
|
||
| In **client SDKs**, you can now: | ||
|
|
||
| - **Per-subscription lifecycle**: Call `unsubscribe()` on a subscription handle to stop that listener only; other subscriptions on the same `Realtime` instance keep running. | ||
| - **`update()`**: Change `channels` and `queries` on an existing subscription in place, without recreating the client. | ||
| - **`disconnect()`**: Close the WebSocket and drop every active subscription in one call when your app is done with Realtime (for example on component unmount). | ||
|
|
||
| Together, the Realtime protocol change and matching SDK APIs reduce unnecessary reconnects, make UI-driven subscription changes easier to reason about, and move subscription state off the WebSocket URL onto incremental messages over the open connection. | ||
|
|
||
| Read the announcement on the blog for context and examples, and see the [Realtime documentation](/docs/apis/realtime) for how this maps to your platform. | ||
|
|
||
| {% arrow_link href="/blog/post/announcing-message-based-realtime-sdk" %} | ||
| Read the blog announcement | ||
| {% /arrow_link %} | ||
|
|
||
| {% arrow_link href="/docs/apis/realtime/subscribe" %} | ||
| Manage subscriptions in the docs | ||
| {% /arrow_link %} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.