diff --git a/src/routes/blog/post/announcing-message-based-realtime-sdk/+page.markdoc b/src/routes/blog/post/announcing-message-based-realtime-sdk/+page.markdoc new file mode 100644 index 0000000000..e77c96c23b --- /dev/null +++ b/src/routes/blog/post/announcing-message-based-realtime-sdk/+page.markdoc @@ -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://.cloud.appwrite.io/v1') + .setProject(''); + +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://.cloud.appwrite.io/v1') + .setProject(''); + +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://.cloud.appwrite.io/v1") + .setProject("") + +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://.cloud.appwrite.io/v1") + .setProject("") + +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://.cloud.appwrite.io/v1") + .setProject(""); + +Realtime realtime = new Realtime(client); + +RealtimeSubscription sub1 = realtime.subscribe( + new String[] {"files"}, + (RealtimeResponseEvent response) -> { + System.out.println("files " + response); + return Unit.INSTANCE; + } +); + +RealtimeSubscription sub2 = realtime.subscribe( + new String[] {"account"}, + (RealtimeResponseEvent 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://.cloud.appwrite.io/v1') + .setProject(''); + +const realtime = new Realtime(client); + +const subscription = await realtime.subscribe(Channel.files(), response => { + console.log(response); +}); + +await subscription.update({ + channels: [Channel.tablesdb('').table('').row('')], + queries: [Query.equal('title', ['todo'])], +}); +``` + +```client-flutter +import 'package:appwrite/appwrite.dart'; + +final client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') + .setProject(''); + +final realtime = Realtime(client); + +final subscription = realtime.subscribe([Channel.files()]); + +subscription.stream.listen((response) { + print(response); +}); + +await subscription.update( + channels: [Channel.tablesdb('').table('').row('')], + queries: [Query.equal('title', ['todo'])], +); +``` + +```client-apple +import Appwrite +import AppwriteModels + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + +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("").table("").row("")], + 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://.cloud.appwrite.io/v1") + .setProject("") + +val realtime = Realtime(client) + +val subscription = realtime.subscribe(Channel.files()) { + print(it.payload.toString()) +} + +subscription.update(RealtimeSubscriptionUpdate( + channels = listOf(Channel.tablesdb("").table("").row("")), + 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://.cloud.appwrite.io/v1") + .setProject(""); + +Realtime realtime = new Realtime(client); + +RealtimeSubscription subscription = realtime.subscribe( + new String[] {"files"}, + (RealtimeResponseEvent response) -> { + System.out.println(response); + return Unit.INSTANCE; + } +); + +subscription.update(new RealtimeSubscriptionUpdate( + Arrays.asList("tablesdb..tables..rows."), + 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. +- **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) diff --git a/src/routes/changelog/(entries)/2026-04-29.markdoc b/src/routes/changelog/(entries)/2026-04-29.markdoc new file mode 100644 index 0000000000..2d3ddd6fb7 --- /dev/null +++ b/src/routes/changelog/(entries)/2026-04-29.markdoc @@ -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 %} diff --git a/src/routes/docs/apis/realtime/+page.markdoc b/src/routes/docs/apis/realtime/+page.markdoc index 9686eca066..00539e3010 100644 --- a/src/routes/docs/apis/realtime/+page.markdoc +++ b/src/routes/docs/apis/realtime/+page.markdoc @@ -69,9 +69,9 @@ let subscription = realtime.subscribe(channels: [Channel.files()]) { response in ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") @@ -130,9 +130,9 @@ While the Realtime API offers robust capabilities, there are currently some limi ## Subscription changes {% #subscription-changes %} -The SDK uses a single WebSocket connection for all subscriptions. Adding a subscription with `subscribe()`, dropping one with `subscription.unsubscribe()`, or replacing channels and queries via `subscription.update(...)` all happen on the existing socket — no reconnect required. The connection is only torn down when you call `realtime.disconnect()`, or when the legacy `subscription.close()` runs on the last remaining subscription. +Client SDKs use a **single WebSocket** per `Realtime` client for all subscriptions. Adding a subscription with `subscribe()`, dropping one with `subscription.unsubscribe()`, or replacing channels and queries via `subscription.update(...)` applies on the existing socket where supported — no full reconnect required. The connection is torn down when you call `realtime.disconnect()`, or when the legacy `subscription.close()` runs on the last remaining subscription. -Subscriptions should still be managed alongside your application state so they aren't unnecessarily recreated during component lifecycle changes. +Manage subscription handles alongside your application state so you unsubscribe or disconnect when listeners are no longer needed. See [Subscribe](/docs/apis/realtime/subscribe) for platform-specific APIs. ## Server SDKs {% #server-sdks %} diff --git a/src/routes/docs/apis/realtime/channels/+page.markdoc b/src/routes/docs/apis/realtime/channels/+page.markdoc index ba52359803..bc0e1cba7c 100644 --- a/src/routes/docs/apis/realtime/channels/+page.markdoc +++ b/src/routes/docs/apis/realtime/channels/+page.markdoc @@ -75,9 +75,9 @@ let docSubscription = realtime.subscribe( ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") diff --git a/src/routes/docs/apis/realtime/queries/+page.markdoc b/src/routes/docs/apis/realtime/queries/+page.markdoc index f8f1a9c535..9c7618f0d6 100644 --- a/src/routes/docs/apis/realtime/queries/+page.markdoc +++ b/src/routes/docs/apis/realtime/queries/+page.markdoc @@ -119,10 +119,10 @@ let otherVotes = realtime.subscribe( ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.Query import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") diff --git a/src/routes/docs/apis/realtime/subscribe/+page.markdoc b/src/routes/docs/apis/realtime/subscribe/+page.markdoc index da1991694d..22552b662e 100644 --- a/src/routes/docs/apis/realtime/subscribe/+page.markdoc +++ b/src/routes/docs/apis/realtime/subscribe/+page.markdoc @@ -4,7 +4,7 @@ title: Subscribe description: Learn how to subscribe to realtime events from Appwrite services. Subscribe to single or multiple channels and manage your subscriptions. --- -The Appwrite Realtime API lets you subscribe to events from any Appwrite service through [channels](/docs/apis/realtime/channels). You can subscribe to a single channel, multiple channels at once, and unsubscribe when you no longer need updates. +The Appwrite Realtime API lets you subscribe to events from any Appwrite service through [channels](/docs/apis/realtime/channels). You can subscribe to a single channel, multiple channels at once, and unsubscribe when you no longer need updates. On supported client SDKs (including the Web SDK), multiple subscriptions share one WebSocket and can be added, **updated**, or removed without reconnecting the whole client until you call **`disconnect()`** on `Realtime`. # Subscribe to a channel {% #subscribe-to-a-channel %} @@ -60,9 +60,9 @@ let subscription = realtime.subscribe(channels: [Channel.account()]) { response ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") @@ -77,7 +77,6 @@ val subscription = realtime.subscribe(Channel.account()) { ``` ```client-android-java -import io.appwrite.Channel; import io.appwrite.Client; import io.appwrite.models.RealtimeResponseEvent; import io.appwrite.models.RealtimeSubscription; @@ -89,10 +88,9 @@ Client client = new Client(context) .setProject(""); Realtime realtime = new Realtime(client); -String accountChannel = Channel.Companion.account(); RealtimeSubscription subscription = realtime.subscribe( - new String[] {accountChannel}, + new String[] {"account"}, (RealtimeResponseEvent response) -> { // Callback will be executed on all account events. System.out.println(response); @@ -168,9 +166,9 @@ realtime.subscribe(channels: [ ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") @@ -215,7 +213,7 @@ RealtimeSubscription subscription = realtime.subscribe( # Update channels or queries {% #update %} -Channels and queries on an active subscription can be replaced in place without recreating the WebSocket. This is useful when, for example, a user changes which row they're viewing — swap the channel on the existing subscription instead of unsubscribing and resubscribing. +Channels and queries on an active subscription can be replaced in place without recreating the WebSocket. This is useful when, for example, a user changes which row they're viewing. Swap the channel on the existing subscription instead of unsubscribing and resubscribing. `update()` accepts either or both of `channels` and `queries`. Pass only the field you want to replace; omitted fields are left unchanged. @@ -286,9 +284,9 @@ try await subscription.update(RealtimeSubscriptionUpdate( ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel import io.appwrite.models.RealtimeSubscriptionUpdate val client = Client(context) @@ -344,7 +342,7 @@ subscription.update(new RealtimeSubscriptionUpdate( # Unsubscribe {% #unsubscribe %} -If you no longer want to receive updates from a particular subscription, call `unsubscribe()` on it. Other subscriptions and the underlying WebSocket connection are not affected, so callbacks for the rest of your app keep firing. To tear the whole connection down at once, see [Disconnect](#disconnect) below. +If you no longer want to receive updates from a particular subscription, call `unsubscribe()` on it. Other subscriptions and the underlying WebSocket connection are not affected, so callbacks for the rest of your app keep firing. To close the entire connection at once, see [Disconnect](#disconnect) below. {% multicode %} ```client-web @@ -389,6 +387,9 @@ await subscription.unsubscribe(); import Appwrite let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") + .setProject("") + let realtime = Realtime(client) let subscription = realtime.subscribe(channels: [Channel.files()]) { response in @@ -401,9 +402,9 @@ try await subscription.unsubscribe() ``` ```client-android-kotlin +import io.appwrite.Channel import io.appwrite.Client import io.appwrite.services.Realtime -import io.appwrite.extensions.Channel val client = Client(context) .setEndpoint("https://.cloud.appwrite.io/v1") @@ -449,12 +450,12 @@ subscription.unsubscribe(); {% /multicode %} {% info title="Legacy close()" %} -`subscription.close()` still works for backwards compatibility — it calls `unsubscribe()` and additionally closes the WebSocket if this was the last active subscription. New code should prefer `unsubscribe()` and call `realtime.disconnect()` explicitly when full teardown is needed. +`subscription.close()` still works for backwards compatibility. It calls `unsubscribe()` and additionally closes the WebSocket if this was the last active subscription. New code should prefer `unsubscribe()` and call `realtime.disconnect()` explicitly when full teardown is needed. {% /info %} # Disconnect {% #disconnect %} -Call `realtime.disconnect()` to drop **all** active subscriptions and close the WebSocket in one step. Use this at app teardown or when a user logs out — there's no need to loop over each subscription and call `unsubscribe()` first. +Call `realtime.disconnect()` to drop **all** active subscriptions and close the WebSocket in one step. Use this at app teardown or when a user logs out. {% multicode %} ```client-web diff --git a/static/images/blog/announcing-message-based-realtime-sdk/cover.jpg b/static/images/blog/announcing-message-based-realtime-sdk/cover.jpg new file mode 100644 index 0000000000..95210f0ecc Binary files /dev/null and b/static/images/blog/announcing-message-based-realtime-sdk/cover.jpg differ