Upgrade stream-chat to v10
This page lists what changed between v9 and v10 of stream-chat, and what to change in your app. It covers apps that call stream-chat directly: vanilla JavaScript, Angular, Vue and Svelte apps, custom UIs, and Node scripts or bots that connect as a user.
If your app uses stream-chat-react or stream-chat-react-native, follow Upgrade to v15 or Upgrade to v10 instead. They upgrade stream-chat together with the UI SDK. Use this page as well for the calls your app makes to stream-chat directly.
The complete list of changes, with every renamed type, is stream-chat v9 to v10 breaking changes in the stream-chat repository. This page covers the changes most apps meet, and links to that list for the rest.
Quick migration checklist
- Upgrade Node and the package. Install with Node 22.18 or later, then
npm install stream-chat@10. See Install and runtime. - Move server-side code to
@stream-io/node-sdk.stream-chat10 no longer takes an API secret. See Server-side code. - Move client options under
client. See Creating the client. - Update how you read data. Custom fields are under
custom, server timestamps are nanosecond numbers, and sorts are arrays. See Data shapes. - Replace
connection.changedlisteners with the connection stores. See Connection status. - Pass request objects to client and channel methods, and replace
client.queryChannels()withclient.queryChannelsAndHydrate()where you needChannelobjects. See Client methods. - Read messages from the message paginator, not from
channel.state. See Channel state and the message list. - Rewrite channel lists with a
ChannelPaginatorregistered onclient.channelManager. See Channel lists. - Activate the channels and threads on screen, so they recover after a reconnect. See Activate what is on screen.
- Walk Breaks that compile. Several changes compile, and plain JavaScript gets no compile errors at all.
AI-assisted migration
The stream-chat repository has a migration guide written for coding agents. It lists a detection grep for every change, applies them in order, and ends with a verification step. To drive the migration with an AI assistant, use a prompt like this:
Migrate this app from stream-chat v9 to v10.
Use the installed package in node_modules as the source of truth.
Before using any symbol, verify it in node_modules/stream-chat/dist/types.
Follow this migration guide:
- https://github.com/GetStream/stream-chat-js/blob/master/ai-docs/ai-migration-v9-to-v10.md
Look up anything it does not cover in:
- https://github.com/GetStream/stream-chat-js/blob/master/ai-docs/v10-breaking-changes.md
Do the migration in phases:
1. preflight: record the installed versions, the Node version, and the baseline typecheck, lint and test results
2. install stream-chat 10, and move server-side code to @stream-io/node-sdk
3. run the guide's detection greps, and apply the sections that have hits
4. walk the "Breaks that compile" table
5. verify with the typecheck, lint, tests and the leftover-identifier grep
Stop after each phase and report the exact changes and remaining errors.What's new
The state pages describe how v10 holds data:
- State overview: stores, subscriptions, and the paginators every list is built on.
- Messages: the message paginator, loading older messages, and jumping to a message.
- Channel and Thread: their stores, read state, and activation.
- Channel list and Thread list:
ChannelPaginator,client.channelManager, andclient.threads.paginator. - Connection: the device's network and the WebSocket as two separate stores, and recovery after a reconnect.
- Message composer: sending messages with an optimistic update.
Install and runtime
stream-chat 10 requires Node 22.18 or later in its engines field. Use it wherever you install or build, including CI and Docker images, even for a browser app. Yarn 1 refuses to install on an older Node, and npm and pnpm print a warning.
v10 no longer depends on ws, jsonwebtoken, form-data, base64-js or isomorphic-ws. If your app imports one of them, add it to your own package.json. Bundler shims you added for v9, such as webpack resolve.fallback entries for crypto, https, zlib or ws, can be removed.
With skipLibCheck: false in your tsconfig, the v10 declarations need @types/node and the dom lib. Keep skipLibCheck: true, or add both.
WebSocket on Node
Browsers and React Native are unaffected. On Node, the client uses the global WebSocket that Node 22 provides. To use ws anyway, pass it as the socket implementation:
import WS from "ws";
import { StreamChat } from "stream-chat";
const client = new StreamChat(apiKey, {
client: {
allowServerSideConnect: true,
wsConnection: { webSocketImpl: WS as unknown as typeof WebSocket },
},
});StateStore moved to its own package
StateStore, MergedStateStore, isPatch and the Unsubscribe, Patch and ValueOrPatch types are no longer exported from stream-chat. Import them from @stream-io/state-store, add that package to your dependencies with the range stream-chat declares, and keep one copy of it installed. A second copy makes every StateStore type incompatible with the ones stream-chat returns.
- import { StateStore, type Unsubscribe } from "stream-chat";
+ import { StateStore, type Unsubscribe } from "@stream-io/state-store";Server-side code
stream-chat 10 is client-side only. These are removed:
- the
secretconstructor argument,client.secretandclient.createToken() - the webhook, SQS and SNS helpers, such as
verifyWebhook,verifySignature,parseSqsandparseSns - about 90 admin methods: app settings, push providers, user administration, channel types, commands, roles and permissions, exports and imports, campaigns, segments, batch updates and retention
- the
user_id"on behalf of" overrides on every method. The connected user is the one who acts
Move that code to @stream-io/node-sdk. Don't port it onto stream-chat 10, and don't keep a secret in code that imports stream-chat.
In v9:
const server = new StreamChat(apiKey, apiSecret);
const token = server.createToken("jane");In v10:
import { StreamClient } from "@stream-io/node-sdk";
const server = new StreamClient(apiKey, apiSecret);
const token = server.generateUserToken({
user_id: "jane",
validity_in_seconds: 60 * 60,
});The server-only methods entry maps each removed method to its @stream-io/node-sdk replacement. client.revokeTokens() still exists, but revokes only the connected user's tokens. App-wide revocation moved to @stream-io/node-sdk too.
Creating the client
new StreamChat(apiKey, config?) and StreamChat.getInstance(apiKey, config?) take one configuration tree, the same tree client.config.set() takes. Most of the options v9 took at the top level now go under client.
In v9:
const client = StreamChat.getInstance(apiKey, {
timeout: 6000,
enableWSFallback: true,
recoverStateOnReconnect: false,
isLocalUnreadCountEnabled: true,
});In v10:
import { StreamChat } from "stream-chat";
const client = StreamChat.getInstance(apiKey, {
client: {
axiosRequestConfig: { timeout: 6000 },
connectionRecovery: { enabled: false },
wsConnection: { enableWSFallback: true },
},
channel: { readEvents: { localUnreadCountEnabled: true } },
});In plain JavaScript, a v9 options object still runs. The client ignores its keys and logs a warning for each, so the options silently stop applying. A v9 call that passes a secret logs one warning per character of the secret.
Where the v9 options went:
| v9 option | v10 setting |
|---|---|
timeout, headers, withCredentials, httpsAgent and the other top-level axios options |
client.axiosRequestConfig |
axiosRequestConfig |
client.axiosRequestConfig |
baseURL, browser, allowServerSideConnect, persistUserOnConnectionFailure |
client.<same name> |
enableWSFallback |
client.wsConnection.enableWSFallback |
wsUrlParams |
client.wsConnection.urlParams |
recoverStateOnReconnect |
client.connectionRecovery.enabled |
isLocalUnreadCountEnabled |
channel.readEvents.localUnreadCountEnabled |
notifications |
client.notificationManager |
logger |
removed. See Logging |
device |
removed. Call client.createDevice() after connecting |
warmUp, enableInsights, disableCache |
removed |
The client properties that mirrored options are gone too:
| v9 | v10 |
|---|---|
client.options.x |
client.config.getConfig("client")?.x |
client.recoverStateOnReconnect |
client.connectionRecovery.config.enabled |
client.defaultWSTimeout |
client.wsConnection.config.connectTimeoutMs |
client.persistUserOnConnectionFailure = v |
client.config.setConfig("client", { persistUserOnConnectionFailure: v }) |
client.userID, client.clientID |
client.userId, client.clientId. The old names are deprecated |
client.setMessageComposerSetupFunction(fn) |
client.config.setSetupFunction("messageComposer", fn). The old name is deprecated |
A few behaviors changed with them:
persistUserOnConnectionFailure: truenow keeps the socket retrying after a network failure, where v9 closed it. Callclient.closeConnection()yourself if your app relied on the socket stopping.- The SDK's auth headers always win over headers you set, and there is no default keep-alive agent on Node. Pass
httpsAgentif you relied on it. client.devToken()and string tokens fail for user ids with non-ASCII characters or>,?,~, because the token is decoded with the globalatob. Give those users a token provider:client.connectUser(user, () => fetchToken()).
Logging
options.logger, client.logger and the Logger type are removed. Logging goes through chatLoggerSystem, exported from stream-chat, and configured once for the process.
The default changed: v10 logs info and above to the console, where v9 was silent.
import { chatLoggerSystem } from "stream-chat";
// Send warnings and errors to your own logger
chatLoggerSystem.configureLoggers({
default: {
level: "warn",
sink: (level, message, ...data) => myLog(level, message, data),
},
});
// Or keep the v9 silence
chatLoggerSystem.configureLoggers({ default: { sink: () => {} } });Configure it before you create the client. LogLevel is "trace" | "debug" | "info" | "warn" | "error", so an exhaustive switch over it needs the two new levels.
Data shapes
stream-chat 10 types its data from the API's specification. Three changes reach almost every app.
Custom fields are under custom
Your own fields on a message, a channel, a user, an attachment, an event or another object are no longer spread at the top level. They're in the object's custom field. Plain JavaScript that reads a top-level custom field gets undefined, with no error.
| v9 | v10 |
|---|---|
message.priority |
message.custom.priority |
channel.data?.name |
channel.data?.custom?.name |
channel.data?.image |
channel.data?.custom?.image |
attachment.mime_type, file_size |
attachment.custom.mime_type, attachment.custom.file_size |
attachment.duration, waveform_data |
attachment.custom.duration, attachment.custom.waveform_data |
channel.sendMessage({ text, priority }) |
channel.sendMessage({ message: { text, custom: { priority } } }) |
client.channel(type, id, { name }) |
client.channel(type, id, { custom: { name } }) |
The channel's name and image are the reads most apps have to fix: in headers, list previews and search results.
Two places stay flat:
- Filters. Filter a channel's name as
name. Filter other custom fields as"custom.<field>". See Sorts and filters. - Partial updates. The
setandunsetofclient.updateUsersPartial()andchannel.updatePartial()take built-in and custom keys side by side. Don't wrap them incustom.
await channel.updatePartial({ set: { name: "General", color: "blue" } });The declare module "stream-chat" augmentation of the Custom*Data interfaces still works, and now types custom. stream-chat doesn't declare a channel's name or image, so declare them on CustomChannelData if you read them:
import "stream-chat";
declare module "stream-chat" {
interface CustomChannelData {
image?: string;
name?: string;
}
interface CustomMessageData {
priority?: string;
}
}CustomCommandData is removed: a command's name and set are plain strings. Delete its augmentation and any import of it. A leftover augmentation still compiles, so search for it.
Timestamps are nanosecond numbers
Every date the server sends, in responses, events and state, is a TimestampNS: Unix time in nanoseconds, as a number. In v9 it was an ISO string, and formatted messages carried Date objects. Dates you send in a request, such as remind_at, are Date objects. In v9 remind_at was an ISO string, and a string no longer compiles.
stream-chat exports the helpers to convert them: nsToDate, convertTimestampToDate, nsToMs, msToNs, dateToNs, nowNs and asTimestampNS.
In v9:
const sentAt = new Date(message.created_at);
const isRecent = Date.parse(message.created_at) > Date.now() - 60_000;In v10:
import { msToNs, nowNs, nsToDate } from "stream-chat";
const sentAt = nsToDate(message.created_at);
const isRecent = message.created_at > nowNs() - msToNs(60_000);These fail without a type error, so check for them by hand:
dayjs(timestamp)andmoment(timestamp)read nanoseconds as milliseconds, and render dates far in the future.timestamp > Date.now()compares nanoseconds with milliseconds.if (!message.created_at)is wrong for a value of0.- ISO strings or
Dateobjects you stored yourself no longer compare with fresh values. Convert what you read from your own storage withasTimestampNS().
new Date(timestamp) is marked deprecated, and fails to compile where you use the result as a Date. Replace it with nsToDate(timestamp) everywhere.
LocalMessage uses undefined instead of null for deleted_at, pinned_at, quoted_message and error. Replace === null checks with == null.
Sorts and filters
Every sort is an array of { field, direction } objects, typed SortParamRequest[]. The object form and the 29 per-endpoint sort types, such as ChannelSort, UserSort and MemberSort, are removed. Keep the order of the fields: the first one sorts first.
import type { SortParamRequest } from "stream-chat";
// v9: { pinned_at: -1, last_message_at: -1 }
const sort: SortParamRequest[] = [
{ direction: -1, field: "pinned_at" },
{ direction: -1, field: "last_message_at" },
];Filters are generated per endpoint, and ChannelFilters, UserFilters, MemberFilters and MessageFilters are still exported. A custom field is a "custom.<field>" key, and each key takes one operator. Split two operators on one key into an $and.
const filters: ChannelFilters = {
"custom.color": "green",
members: { $in: [userId] },
};Events
Event is a union of event types. A field exists only on the event types that carry it, so a handler gets the right type from the event name:
import type { EventPayload } from "stream-chat";
const { unsubscribe } = client.on("message.new", (event) => {
console.log(event.message.text);
});
// A handler declared elsewhere
const onNewMessage = (event: EventPayload<"message.new">) => {
console.log(event.message.id);
};
client.on("message.new", onNewMessage);- Drop explicit
(event: Event) =>annotations. They widen the event back to the whole union. client.on()returns{ unsubscribe }, and accepts only known event names. Listen to every event withclient.on(handler)orclient.on("all", handler)."*"isn't an event name.EventTypesisEventType, a type only.EVENT_MAPis removed, so keep your own list if you iterated it.channel.listenersandclient.listenersare aMapofSets:channel.listeners.get("message.new")?.size. A handler registered twice is registered once.
These events are removed:
| v9 event | v10 |
|---|---|
connection.changed |
client.wsConnection.state and client.networkConnection.state. See Connection status |
transport.changed |
the local connection.fallback_activated event |
capabilities.changed |
ownCapabilities in channel.state |
channel.muted, channel.unmuted |
notification.channel_mutes_updated |
user.unread_message_reminder |
removed |
To follow a channel's capabilities, subscribe to channel.state. The handler runs only when the capabilities change:
channel.state.subscribeWithSelector(
({ ownCapabilities }) => ({ ownCapabilities }),
({ ownCapabilities }) => renderToolbar(ownCapabilities),
);The AI state AI_STATE_CHECKING_SOURCES is now AI_STATE_EXTERNAL_SOURCES. Compare against AIStates.ExternalSources.
Custom events
A custom event carries its fields in event.custom, and its name must be declared:
declare module "stream-chat" {
interface CustomEventTypes {
"friendship.request": true;
}
}
await channel.sendEvent({
event: { custom: { from: "jane" }, type: "friendship.request" },
});
client.on("friendship.request", (event) => console.log(event.custom));In v9, channel.sendEvent({ type, text }) sent the fields at the top level.
Connection status
In v9, "online" meant that the WebSocket was up. stream-chat 10 reports two things separately: whether the device has a network, in client.networkConnection, and whether the socket is up, in client.wsConnection. Connection describes both.
connection.changed is never dispatched. In TypeScript a listener for it doesn't compile. In JavaScript it compiles and never runs, so an offline banner never shows or never hides.
In v9:
client.on("connection.changed", (event) => setOnline(!!event.online));In v10:
const unsubscribe = client.wsConnection.state.subscribeWithSelector(
({ isHealthy }) => ({ isHealthy }),
({ isHealthy }) => setOnline(isHealthy),
);- The store calls the handler once right away, and then on every change. v9 held a drop back for about five seconds, so a short flap didn't flash a banner. To keep that, wait
client.wsConnection.config.offlineNotificationDisplayDelayMsyourself, and cancel when the socket comes back. isOnlineinclient.networkConnection.stateisundefineduntil the network is first reported, so test it with=== false. Browsers get a network reporter automatically. On other hosts, register one withclient.networkConnection.setStatusReporter().client.wsConnectionalways exists, beforeconnectUser()too. Checkclient.wsConnection.isHealthy, notif (client.wsConnection).client.wsFallbackis removed.- With
enableWSFallback, the socket waits the fullclient.wsConnection.config.connectTimeoutMs, 15 seconds by default, before it switches to long polling. Lower it to switch sooner.
REST requests go to /api/v2/..., and the socket connects to /api/v2/connect with the token in its first frame. Update proxies, CSP allowlists and request mocks keyed on paths. The exception is client.unbanUser(), which channel.unbanUser() calls too: it still sends DELETE /moderation/ban, without /api/v2.
Errors
A failed request whose response has a Stream error code rejects with StreamAPIError, which has code, status and response. Its message is the server's message, without the StreamChat error code N: prefix. Anything else, such as a network error, a 502 page or a cancelled request, rejects with the raw axios error.
In v9:
try {
await channel.sendMessage({ text });
} catch (err) {
if (err instanceof ErrorFromResponse && err.code === 17) showNotAllowed();
}In v10:
import { StreamAPIError } from "stream-chat";
try {
await channel.sendMessage({ message: { text } });
} catch (err) {
if (err instanceof StreamAPIError && err.code === 17) showNotAllowed();
else throw err;
}ErrorFromResponse, client.errorFromResponse() and client.handleResponse() are removed. Read err.response?.data.status_code instead of StatusCode. Code that matched on the error message text, such as err.message.includes("StreamChat error"), no longer matches.
When a request fails with an expired token and the user connected with a token provider, the client loads a new token and retries the request. v10 retries up to 10 times, and then rejects with the StreamAPIError. v9 retried with no limit.
Client methods
Methods take request objects
Most methods take path parameters first, then the request object, then requestOptions: (pathParams?, request?, requestOptions?). requestOptions is a StreamRequestOptions, with signal, onUploadProgress and timeout, and replaces v9's RequestOptions. A method with no request body keeps an unused request slot, so pass undefined to reach requestOptions.
In v9:
const { users } = await client.queryUsers(
{ id: { $in: ids } },
{ last_active: -1 },
{ limit: 10 },
);
const { results } = await client.search({ cid: channel.cid }, "hello", {
limit: 20,
});In v10:
const { users } = await client.queryUsers({
payload: {
filter_conditions: { id: { $in: ids } },
limit: 10,
sort: [{ direction: -1, field: "last_active" }],
},
});
const { results } = await client.search({
payload: {
filter_conditions: { cid: channel.cid },
limit: 20,
query: "hello",
},
});The same rule changed queryBannedUsers, queryMessageFlags, translateMessage({ id }, { language }), getMessage({ id }), sync({ channel_cids, last_sync_at }), deleteChannels({ cids, hard_delete }), queryDrafts, the user group methods, and more. The generated client signatures entry lists them.
client.get(),post(),put(),patch(),delete()anddoAxiosRequest()moved toclient.api.client.createAbortControllerForNextRequest()is removed. PassrequestOptions.signal.- Responses carry the body plus
metadata.blocklistis removed from responses.
queryChannels returns the response
client.queryChannels(request?, requestOptions?) is now a plain request. It returns the response object, creates no Channel objects, stores nothing, and dispatches no channels.queried. In JavaScript, const channels = await client.queryChannels(...) now gets an object, with no .length.
The v9 behavior is client.queryChannelsAndHydrate():
In v9:
const channels = await client.queryChannels(
{ members: { $in: [userId] } },
{ last_message_at: -1 },
{ limit: 20 },
);In v10:
const channels = await client.queryChannelsAndHydrate({
filter_conditions: { members: { $in: [userId] } },
limit: 20,
sort: [{ direction: -1, field: "last_message_at" }],
});The state options stay the second argument, and the abort signal moves to the third. stateOptions.skipInitialization is removed: activate the channels on screen instead. For a channel list that stays current, use a ChannelPaginator.
Plain requests and AndHydrate
The same split applies to threads, reactions and message edits:
| v9 | v10 plain request | v10 that also updates local state |
|---|---|---|
client.queryThreads(options) |
client.queryThreads(request) |
client.queryThreadsAndHydrate(options) |
client.getThread(id) |
client.getThread({ message_id }) |
client.getThreadAndHydrate({ message_id }) |
client.queryReactions(id, filter, sort) |
client.queryReactions({ id }, request) |
client.queryReactionsAndHydrate({ id }, request) |
client.updateMessage(message) |
client.updateMessage({ id }, { message }) |
channel.messageOperations.update({ localMessage }) |
client.deleteMessage(id, hard) |
client.deleteMessage({ id }, { hard }) |
channel.messageOperations.delete({ localMessage, options: { hard } }) |
The plain requests have no offline queue and change no local state.
Renamed methods
| v9 | v10 |
|---|---|
client.addDevice(id, push_provider) |
client.createDevice({ id, push_provider, push_provider_name? }) |
client.getDevices() |
client.listDevices() |
client.removeDevice(id) |
client.deleteDevice({ id }) |
client.setLocalDevice(device) |
removed. Call client.createDevice() after connecting |
client.getUnreadCount() |
client.unreadCounts() |
client.setPushPreferences(preferences) |
client.updatePushNotificationPreferences({ preferences }) |
client.partialUpdateUser(user), client.partialUpdateUsers(users) |
client.updateUsersPartial({ users: [{ id, set, unset }] }) |
client.upsertUser(user), client.upsertUsers(users), client.updateUser(user) |
client.updateUsers({ users: { [id]: user } }) |
client.unBlockUser(id) |
client.unblockUser(id) |
client.queryUserGroups(options) |
client.listUserGroups(options) |
client.getSharedLocations() |
client.getUserLiveLocations() |
client.updateLocation(payload) |
client.updateLiveLocation({ message_id, latitude, longitude, end_at }) |
client.markAllRead() |
client.markChannelsRead() |
client.partialUpdateMessage(id, { set, unset }) |
client.updateMessagePartial({ id }, { set, unset }) |
client.ephemeralUpdateMessage(...) |
client.updateMessagePartial(...) |
client.partialUpdateThread(messageId, { set, unset }) |
client.updateThreadPartial({ message_id }, { set, unset }) |
client.enrichURL(url) |
client.getOG({ url }) |
client.hydrateActiveChannels(...) |
client.hydrateChannels(...) |
client.setAnonymousUser() |
client.connectAnonymousUser() |
client.recoverState() |
client.connectionRecovery.recover() |
client.pinMessage(message, 120) |
client.pinMessage(message, { pin_expires: new Date(Date.now() + 120_000) }) |
client.unpinMessage(message, userId) |
client.unpinMessage(message) |
Moderation
Moderation calls moved to client.moderation, and take request objects:
| v9 | v10 |
|---|---|
client.banUser(id, options) |
client.moderation.ban({ target_user_id: id, ...options }) |
client.shadowBan(id, options) |
client.moderation.ban({ target_user_id: id, shadow: true }) |
client.removeShadowBan(id) |
client.unbanUser(id, { shadow: true }) |
client.muteUser(id) |
client.moderation.mute({ target_ids: [id] }) |
client.unmuteUser(id) |
client.moderation.unmuteUser(id) |
client.flagMessage(id) |
client.moderation.flagMessage({ message_id: id }) |
client.flagUser(id) |
client.moderation.flagUser({ user_id: id }) |
unflagMessage, unflagUser |
removed |
client.unblockMessage(id) |
removed |
Mute, unmute and flag need moderation v2 enabled for your app. v9's client.muteUser(), client.unmuteUser(), client.flagMessage() and client.flagUser() called /moderation/mute, /moderation/unmute and /moderation/flag. Their v10 replacements call /api/v2/moderation/mute, /api/v2/moderation/unmute and /api/v2/moderation/flag. Without moderation v2 these calls reject with 403 at runtime, the first time a user mutes or flags. Ask Stream support to enable it before you upgrade, and handle the rejection where your app mutes or flags.
Uploads
Uploads take a request object with a file: a browser File or Blob, a { uri, name, type } reference, or a URI string. Node Buffers and streams are no longer accepted, so pass a Blob. There is no contentType argument: set type on the file. Upload progress, timeout and abort go in requestOptions.
In v9:
await channel.sendImage(file, file.name, file.type, undefined, {
onUploadProgress,
});
await channel.deleteImage(url);In v10:
await channel.uploadImage({ file }, { onUploadProgress });
await channel.deleteImage({ url });
await client.uploadFile({
file: { name: "clip.mp4", type: "video/mp4", uri },
});channel.sendFile() is channel.uploadFile({ file }), and client.sendFile() is removed.
Channels
Get channels from client.channel(type, id, data). Never construct one with new Channel(): a channel the client doesn't hold receives no events. client.getChannelById() and client.getChannelByMembers() are removed, and client.channel() covers both.
watch() resolves with the channel
channel.watch(options?, requestOptions?) resolves with the Channel to use from then on, not with the server's response. That is the channel itself, or, for a channel created from its members only, the channel that superseded it.
In v9:
const response = await channel.watch();
renderMessages(response.messages);In v10:
const watched = await channel.watch();
renderMessages(watched.messagePaginator.items ?? []);channel.watch()sends a request only when the channel isn't loaded yet, or when you pass options it wasn't watched with. Calling it again no longer refreshes a watched channel: callchannel.reload().- Load other messages with the message paginator, not with
channel.watch({ messages }). channel.query(options?, requestOptions?)returns the raw response when you need it. Its v9 second argument, the message set, is removed, and neitherchannel.query()norchannel.create()adds messages to the list.
A request that watches or asks for presence waits for the WebSocket's connection id. That covers channel.watch(), queryChannels and queryChannelsAndHydrate (which watch by default), and queryUsers with presence. v9 sent it without one, the server answered 200, and registered nothing. Call client.connectUser() first. Code without a socket, such as a script, passes watch: false and presence: false. With no socket open or opening, the request throws "No connection id is available".
Activate what is on screen
After a reconnect, the client reloads the registered channel lists, and the channels and threads that are active. A channel that is watched but not active isn't refreshed, and misses the messages sent while it was offline.
Activate a channel while it's on screen. channel.activate() returns the function that releases it, and there is no channel.deactivate():
// When the channel is shown
const release = channel.activate();
// When the user leaves it
release();client.connectionRecovery runs the recovery and dispatches connection.recovered when it's done. Don't also reload active channels on connection.recovered, or each reconnect loads them twice. To turn recovery off, set client.config.set({ client: { connectionRecovery: { enabled: false } } }). connection.recovered still fires after every reconnect, but nothing is reloaded.
Sending messages and reactions
channel.sendMessage() is a plain request with no offline queue. With an offline database, v9 queued a message that had an id there, and v10 no longer does. The optimistic flow, with the failed state, retries and offline replay, goes through channel.messageComposer.send() or channel.messageOperations. See Message composer.
In v9:
await channel.sendMessage({ attachments, text }, { skip_push: true });
await channel.sendReaction(messageId, { type: "love" });
await channel.deleteReaction(messageId, "love");In v10:
// Plain requests
await channel.sendMessage({ message: { attachments, text }, skip_push: true });
await channel.sendReaction({ id: messageId }, { reaction: { type: "love" } });
await channel.deleteReaction({ id: messageId, type: "love" });
// With an optimistic update
await channel.messageOperations.addReaction({
messageId,
reaction: { type: "love" },
});pending, pending_message_metadata, force_moderation and is_pending_message aren't part of SendMessageRequest.
To customize the requests behind the optimistic operations, register request handlers once for the client:
client.config.setConfig("channel", {
requestHandlers: {
sendMessageRequest: async (params, defaultRequest) => {
console.log("Sending to", params.localMessage.cid);
return defaultRequest(params);
},
},
});The handlers are sendMessageRequest, retrySendMessageRequest, updateMessageRequest, deleteMessageRequest and markReadRequest.
markRead
The two mark-read methods swapped names. v9's channel.markRead() went through the delivery reporter. In v10 that is channel.markReadViaReporter(). v10's channel.markRead(request?, requestOptions?) sends the request directly, and replaces v9's markAsReadRequest().
In v9:
await channel.markRead();
await channel.markAsReadRequest();In v10:
await channel.markReadViaReporter();
if (channel.readEventsEnabled) {
await channel.markRead({ message_id });
}channel.markRead() and channel.markUnread() throw when read events are off: for the channel type, in the client configuration, or because the user lacks the read-events capability. v9 resolved null. Replace === null checks with try/catch, or check channel.readEventsEnabled first. channel.markReadViaReporter() and thread.markRead() still resolve null in that case.
The message_timestamp of markUnread() is a Date.
Other channel methods
| v9 | v10 |
|---|---|
channel.update(data, message) |
channel.updatePartial({ set: { name } }), or channel.update({ data: { custom }, message }) |
channel._update(payload) |
channel.update(payload) |
channel.addMembers(ids, message, options) |
channel.addMembers({ members: ids, message, ...options }). The same for removeMembers and inviteMembers |
addModerators, demoteModerators, assignRoles |
removed. Server-side, or addMembers({ members: [{ user_id, channel_role }] }) |
channel.partialUpdateMember(id, update) |
channel.updateMemberPartial({ set, unset }), for your own membership only |
channel.archive(), channel.pin() |
resolve with { channel_member } |
channel.mute({ user_id }) |
channel.mute({ expiration }) |
channel.hide(userId, clearHistory) |
channel.hide({ clear_history }) |
channel.show(userId) |
channel.show() |
channel.shadowBan(id, options) |
channel.banUser(id, { ...options, shadow: true }) |
channel.removeShadowBan(id) |
channel.unbanUser(id, { shadow: true }) |
channel.queryMembers(filter, sort, options) |
channel.queryMembers({ payload: { filter_conditions, sort, ...options } }) |
channel.getPinnedMessages(options, sort) |
channel.getPinnedMessages({ ...options, sort }) |
channel.search(query, options) |
client.search({ payload: { filter_conditions: { cid: channel.cid }, query } }) |
channel.getReplies(parentId, options) |
client.getReplies({ parent_id }, options), or a thread |
channel.getReactions(messageId, options) |
client.getReactions({ id }, options) |
channel.keystroke(parentId, { user_id }) |
channel.keystroke(parentId) |
channel.createDraft(message) |
channel.createDraft({ message }) |
channel.vote(messageId, pollId, vote) |
channel.vote({ message_id, poll_id }, { vote }) |
channel.removeVote(messageId, pollId, voteId) |
channel.removeVote({ message_id, poll_id, vote_id }) |
channel.sendSharedLocation(location, userId) |
channel.sendSharedLocation(location) |
channel.truncate({ user_id, ... }) |
channel.truncate({ hard_delete, message, truncated_at }) |
channel.disconnected, channel._disconnect() |
channel.pendingDisposal, which is read-only, and channel.disconnect() |
channel.lastMessage() |
channel.messagePaginator.latestMessage |
channel.update() is a full update: data.custom replaces all of the channel's custom data. To change one field, use channel.updatePartial({ set: { name } }), or spread channel.data?.custom into the update.
Methods on a channel created from its members, which has no id yet, throw "Channel isn't yet created". Create it with channel.watch() or channel.create() first. That includes channel.queryMembers(), which in v9 queried by the channel's member list instead.
getConfig() and ChannelConfig
channel.getConfig() is the channel.serverConfig getter, and client.configs[cid] is removed. serverConfig is the channel type's settings from the server, and is undefined until the channel is queried. What the client enforces is channel.config, the server's settings combined with yours:
In v9:
const uploadsEnabled = channel.getConfig()?.uploads;
const commands = channel.getConfig()?.commands;In v10:
const uploadsEnabled = channel.serverConfig?.uploads;
const commands = channel.config.availableCommands;
const typingOn = channel.config.typingEvents.enabled;The ChannelConfig type now means that resolved configuration, with typingEvents, readEvents, replies, userMessageReminders, deliveryEvents, availableCommands and requestHandlers. The v9 meaning is ChannelConfigWithInfo. Code typed with ChannelConfig can still compile and read undefined.
Channel state and the message list
channel.state is a store, with getLatestValue(), subscribe() and subscribeWithSelector(). Its members, read, watchers, typing and membership getters still work, but changing them in place notifies no one. Assign a new object instead:
channel.state.read = { ...channel.state.read, [userId]: readState };
channel.data = { ...channel.data, frozen: true };channel.state.unreadCount is read-only. Use channel.markRead() or channel.markReadLocally(). In channel.state.getLatestValue(), the watcher count is watcherCount.
Messages are no longer on channel.state. They're in channel.messagePaginator. See Messages.
In v9:
const messages = channel.state.messages;
const hasOlder = channel.state.messagePagination.hasPrev;
await channel.query({ messages: { id_lt: messages[0].id, limit: 25 } });In v10:
const messages = channel.messagePaginator.items ?? [];
const { hasMoreTail } = channel.messagePaginator.state.getLatestValue();
if (hasMoreTail) await channel.messagePaginator.toTail();
const unsubscribe = channel.messagePaginator.state.subscribeWithSelector(
({ items }) => ({ items }),
({ items }) => renderMessages(items ?? []),
);In a message list, the tail is where older messages load, and the head is the newest message.
| v9 | v10 |
|---|---|
channel.state.messages |
channel.messagePaginator.items, the interval on screen. undefined means not loaded |
channel.state.latestMessages |
channel.messagePaginator.anchoredHeadItems |
channel.state.messagePagination.hasPrev |
hasMoreTail in channel.messagePaginator.state |
channel.state.messagePagination.hasNext |
hasMoreHead in channel.messagePaginator.state |
channel.state.last_message_at |
channel.messagePaginator.latestMessageAt |
channel.lastMessage() |
channel.messagePaginator.latestMessage |
state.addMessageSorted(message) |
channel.messagePaginator.ingestItem(message) |
state.addMessagesSorted(messages) |
channel.messagePaginator.ingestPage({ page: messages }) |
state.removeMessage({ id }) |
channel.messagePaginator.removeItem({ id }) |
state.findMessage(id) |
channel.messagePaginator.getItem(id) |
state.loadMessageIntoState(id) |
channel.messagePaginator.jumpToMessage(id) |
state.clearMessages(), initMessages() |
channel.messagePaginator.clearStateAndCache() |
state.isUpToDate, setIsUpToDate() |
channel.messagePaginator.isActiveIntervalAtHead, setViewingLive(true) |
channel.state.pinnedMessages |
channel.pinnedMessagesPaginator.items |
channel.state.threads[parentId] |
client.threads.get(parentId)?.messagePaginator.items |
A new message always lands in the newest interval. After a jump, items can be an older interval, so read the newest messages from anchoredHeadItems or latestMessage. jumpToTheLatestMessage() and jumpToTheFirstUnreadMessage() move back.
The paginators have no next(), prev(), hasNext or hasPrev. Use toTail(), toHead(), hasMoreTail and hasMoreHead. A cursor's next and prev are tailward and headward. State overview covers the paginator API.
Channel lists
client.activeChannels is removed. The client holds its channels in client.channelManager:
| v9 | v10 |
|---|---|
client.activeChannels[cid] |
client.channelManager.get(cid) |
Object.values(client.activeChannels) |
client.channelManager.values() |
ChannelManager was rewritten. There is one per client, client.channelManager, and it holds any number of lists, each a ChannelPaginator. client.createChannelManager(), new ChannelManager(), manager.queryChannels(), loadNext(), setChannels() and ChannelManagerOptions are removed.
In v9:
const manager = client.createChannelManager({
options: { lockChannelOrder: true },
});
manager.registerSubscriptions();
await manager.queryChannels(
{ members: { $in: [userId] } },
{ last_message_at: -1 },
{ limit: 20 },
);
const { channels } = manager.state.getLatestValue();In v10:
import { ChannelPaginator } from "stream-chat";
const paginator = new ChannelPaginator({
client,
filters: { members: { $in: [userId] } },
id: "main",
paginatorOptions: { lockItemOrder: true, pageSize: 20 },
sort: [{ direction: -1, field: "last_message_at" }],
});
client.channelManager.insertPaginator({ paginator });
const unsubscribe = client.channelManager.registerSubscriptions();
await paginator.toTail();
const channels = paginator.items ?? [];
// Load more: await paginator.toTail()
// Reload: await paginator.reload()
// When the list goes away:
// unsubscribe();
// client.channelManager.removePaginator(paginator);Register the paginator. client.channelManager routes events into the lists it holds, so a paginator that isn't registered shows its first page and then never adds, moves or removes a channel. Channel list covers configuring the query and the event handling.
v9 ChannelManager |
v10 |
|---|---|
channels in manager.state |
items in paginator.state. undefined means never queried |
initialized |
paginator.items !== undefined |
pagination.hasNext, pagination.isLoading |
hasMoreTail, isLoading in paginator.state |
error |
lastQueryError in paginator.state |
setChannels(value) |
paginator.setItems({ valueOrFactory: value }) |
setQueryChannelsRequest(fn) |
paginatorOptions.doRequest |
eventHandlerOverrides |
client.channelManager.setEventHandlers() and addEventHandler() |
lockChannelOrder |
lockItemOrder in each paginator's paginatorOptions |
promoteChannel() |
the position follows the sort. paginator.boost(cid, { ttlMs }) keeps a channel on top for a while |
These change what a list shows without a compile error:
- Order comes from the sort only. A list no longer moves a channel to the top when a message arrives. Sort by
pinned_atandlast_message_atto keep that order. - Filters run on the client. Every event runs the list's full filter, so a channel can leave a list it no longer matches. Teach a list a filter key it can't evaluate with
paginator.addFilterResolvers(). client.channelManager.registerSubscriptions()is ref-counted and returns its unsubscribe. Pair every call with one.client.disconnectUser()resets every list and disposes every channel the client holds. AChannelyou kept throws "pending disposal" after a logout or user switch. Drop your references, and get the channel again withclient.channel(type, id)after the nextconnectUser().
ChannelBatchUpdater and client.channelBatchUpdater() are removed. They're server-side.
Threads
- Open a thread with
client.threads.ensure({ channel, parentMessage }), nevernew Thread(). Fetch threads withclient.getThreadAndHydrate({ message_id })orclient.queryThreadsAndHydrate(). - A thread receives events only while
client.threadsholds it andclient.threads.registerSubscriptions()is active. - Replies moved to
thread.messagePaginator.thread.state.repliesisthread.messagePaginator.items,loadPrevPage()istoTail(), andloadNextPage()istoHead(). thread.activate()returns the function that releases it.thread.deactivate()is removed.thread.markAsRead()is deprecated. Usethread.markRead().- The thread list is
client.threads.paginator, withitems,isLoading,hasMoreTailandlastQueryError.client.threads.threadsById[id]isclient.threads.get(id), andreadyinclient.threads.stateisclient.threads.paginator.items !== undefined. client.preventThreadCleanuphas no effect. Delete the assignment.
In v9:
const thread =
client.threads.threadsById[parentId] ?? new Thread({ client, threadData });
thread.activate();
await thread.loadPrevPage();
thread.deactivate();In v10:
const thread = client.threads.ensure({ channel, parentMessage });
const release = thread.activate();
await thread.messagePaginator.toTail();
release();See Thread and Thread list.
Types
Types are generated from the API's specification, and about 380 v9 type names were renamed or removed. For a type error on a stream-chat import, look the name up in the type rename table. The common ones:
| v9 | v10 |
|---|---|
StreamChatOptions |
InstanceConfigTree |
Message, UpdatedMessage, MessageBase |
MessageRequest |
FormatMessageResponse, MessageResponseBase |
LocalMessage, MessageResponse |
ChannelData |
ChannelInput |
ChannelAPIResponse, QueryChannelAPIResponse, QueryChannelsAPIResponse |
ChannelStateResponseFields, ChannelStateResponse, QueryChannelsResponse |
ChannelQueryOptions |
ChannelGetOrCreateRequest |
ChannelSort, UserSort and the other sort types |
SortParamRequest[] |
EventTypes |
EventType |
ErrorFromResponse, APIErrorResponse |
StreamAPIError, APIError |
Mute, MuteUserResponse |
UserMuteResponse, MuteResponse |
RequestOptions |
StreamRequestOptions |
MarkReadOptions, MarkUnreadOptions |
MarkReadRequest, MarkUnreadRequest |
TranslationLanguages |
TranslationLanguage |
CommandResponse |
Command |
Device, PollVote, PollAnswer, PollOption |
DeviceResponse, PollVoteResponseData, PollVoteResponseData, PollOptionResponseData |
These names still exist and compile, but mean something else. Check every import of them:
| Name | v10 meaning | Use instead |
|---|---|---|
Role |
a role object | string for role names |
User |
an unrelated { id, data? } type |
ClientUser to connect, UserResponse to read |
Reaction |
an activity reaction | ReactionRequest to send, ReactionResponse to read |
PollResponse, PollOptionResponse |
the endpoint responses, { duration, poll } |
PollResponseData, PollOptionResponseData |
MuteResponse |
the mute endpoint's response | UserMuteResponse |
SharedLocationResponse |
the endpoint's response | SharedLocationResponseData |
ThreadResponse |
a minimal thread | ThreadStateResponse |
TypingStartEvent |
the typing.start payload |
EventPayload<"typing.start"> |
ChannelConfig |
the resolved per-channel configuration | ChannelConfigWithInfo |
VotingVisibility |
a type-only union | the string literals, such as "public" |
Some fields became optional, such as res.channel?, and several T | null fields are T | undefined, such as membership, created_by and pinned_by. MessageResponse lost user_id, status and moderation_details, which is moderation. UserResponse lost privacy_settings, invisible and push_notifications. Your own user, an OwnUserResponse, has privacy_settings and invisible, and push_preferences in place of push_notifications. The utility types UR, Readable, KnownKeys, PartializeKeys and ArrayOneOrMore are removed.
Composer, polls, reminders and other managers
If you drive channel.messageComposer directly, or use the poll, reminder, search, live location or offline managers, check the rows that apply. Each links to the full entry.
| Area | Change |
|---|---|
| Composer setup | client.config.setSetupFunction("messageComposer", fn). The composer's configuration is read-only: change it with channel.messageComposer.updateConfig() or client.config.setConfig("messageComposer", ...). More |
| Link previews | On by default where the channel type enriches URLs. Turn them off with client.config.setConfig("messageComposer", { linkPreviews: { enabled: false } }). More |
| Commands | The composer installs the command middlewares by default. Delete your own installs of them. A UI without a command chip removes all four. More |
| Composer output | In the localMessage that channel.messageComposer.compose() returns, deleted_at, pinned_at and reaction_groups are undefined instead of null. The legacy_thread context and applyCommandValidatorOverride are removed. More |
| Uploads | Upload metadata is under custom. A custom doUploadRequest needs customCdn: true. channel.messageComposer.attachmentManager.uploadAttachment() is removed: call channel.messageComposer.attachmentManager.uploadFile(attachment). Sending before uploads finish is attachments.pendingUploadsEnabled in the messageComposer configuration. More |
| Poll composer | channel.messageComposer.pollComposer.updateFields({ data, injectedFieldErrors }). Field errors are PollComposerValidationError objects, not strings. More |
| Polls | Poll methods take path objects: client.partialUpdatePoll() is client.updatePollPartial({ poll_id }, ...), client.castPollVote({ message_id, poll_id }, { vote }), and client.removePollVote() is client.deletePollVote({ message_id, poll_id, vote_id }). client.closePoll() and client.addPollAnswer() are removed. VotingVisibility is a type only: write "public" instead of VotingVisibility.public. More |
| Reminders | client.reminders.createReminder({ message_id }, { remind_at }), where remind_at is a Date. Omit it instead of passing null. More |
| Live location | Call dispose() on a LiveLocationManager when you're done with it. Locations without end_at are dropped. More |
| Cooldown | channel.cooldownTimer.ownLatestMessageDate is channel.cooldownTimer.ownLatestMessageTimestamp. More |
| Search | Sorts are SortParamRequest[]. Channels in search results aren't watched. More |
| Paginator subclasses | query() is split into fetchQueryResponse(), extractQueryPage() and getNextQueryShape(). More |
| Offline storage | The AbstractOfflineDB contract and the pending task payloads changed. More |
| Notifications | The configuration key { client: { notifications } } configures the notification manager, and the client.notifications property is the NotificationManager itself. To use your own manager, pass { client: { notificationManager } } when you create the client. More |
| Utilities | encodeBase64 and decodeBase64 are btoa and atob. Campaign, Segment and the permissions module are removed. More |
Breaks that compile
These type-check, or run untyped in JavaScript, and misbehave. Check each one that touches code you have:
| Symptom | Cause | Fix |
|---|---|---|
| Options ignored, with "unknown key" warnings | a v9 options object, or a secret, passed to the constructor | move the options under client, and drop the secret |
connectUser() rejects "WebSocket is not defined" on Node |
ws is no longer bundled |
use Node 22 or later, or pass wsConnection.webSocketImpl |
| Console output the app never had | the default logger logs info to the console |
chatLoggerSystem.configureLoggers() |
Custom fields read undefined, channel names are blank |
custom data is under custom |
read and write .custom.<field> |
| "Invalid Date", dates far in the future, wrong comparisons | timestamps are nanosecond numbers | nsToDate(), and compare numbers |
| A sort is ignored or rejected | the object form, passed untyped | [{ field, direction }] |
| A custom-field filter matches nothing | custom keys need the custom. prefix |
{ "custom.<field>": value } |
| An offline banner never shows or never hides | connection.changed is never dispatched |
subscribe to client.wsConnection.state |
| Error handling branches never match | ErrorFromResponse is gone, and messages lost their prefix |
err instanceof StreamAPIError, and match err.code or err.status |
client.queryChannels() result has no .length |
it's a plain request now | client.queryChannelsAndHydrate() |
| A query hangs or throws "No connection id is available" | watching and presence wait for the socket | call connectUser() first, or pass watch: false and presence: false |
watch() result read as a response |
watch() resolves with the channel |
read channel.state and channel.messagePaginator, or call query() |
A repeated watch() doesn't refresh |
a watched channel sends nothing for the same options | channel.reload() |
| Messages missing after a reconnect | only active channels reload | channel.activate() while the channel is on screen |
| Each reconnect reloads twice | your own reload on connection.recovered |
remove it for active channels |
| Messages sent offline are no longer queued | channel.sendMessage() has no offline queue |
channel.messageComposer.send() or channel.messageOperations.send() |
| Delivery receipts no longer clear | channel.markRead() is the direct request |
channel.markReadViaReporter() |
markRead() or markUnread() throws instead of resolving null |
read events are off | try/catch, or check channel.readEventsEnabled |
Feature checks read undefined |
getConfig() is removed, ChannelConfig changed meaning |
channel.serverConfig?.<flag> or channel.config.<feature>.enabled |
| The UI doesn't update after a state write | changes in place notify no one | assign a new object |
The message list is empty after channel.query() |
query() adds no messages |
watch(), or channel.messagePaginator.toTail() |
| Channel list order changed | no promotion to the top, the order follows the sort | sort by pinned_at and last_message_at, or paginator.boost() |
| Channels leave or join lists unexpectedly | the full filter runs on the client | review the filters, and add addFilterResolvers() |
A kept Channel throws "pending disposal" after logout |
disconnectUser() disposes every channel |
drop the reference, and call client.channel(type, id) again |
channel.queryMembers() throws "Channel isn't yet created" |
a channel created from its members has no id yet | channel.watch() or channel.create() first |
| A thread never updates | threads get events only through client.threads |
client.threads.ensure(), with client.threads.registerSubscriptions() active |
VotingVisibility.public fails to compile, or throws in JavaScript |
the enum became a type-only union, with no runtime value | "public" |
| Link previews appear where the app had none | link previews are on by default | turn them off in the messageComposer configuration |
| Attachment size or MIME type missing | upload metadata moved under custom |
attachment.custom.mime_type, attachment.custom.file_size |
| Proxy, CSP or request mocks miss requests | REST and WebSocket paths moved under /api/v2 |
update the paths |
Muting or flagging rejects with 403 |
mute, unmute and flag call the moderation v2 endpoints | enable moderation v2 for the app, and handle the rejection |
The socket keeps retrying with persistUserOnConnectionFailure |
it no longer closes after a network failure | call client.closeConnection() yourself |
The behavior index lists every change that still compiles, including rarer ones.
Verify the upgrade
- Run your typecheck, lint and tests.
- Check that one copy of
stream-chat10 resolves, and one copy of@stream-io/state-storeif you import it:npm ls stream-chat @stream-io/state-store. - Run the app and check:
- connecting a user logs no "unknown key" warnings, and the console shows the log level you expect
- the channel list loads, keeps its order, loads more on scroll, and updates on a new message
- a channel's messages render, older messages load, and a sent message shows right away
- channel names and images, custom message fields, and attachment sizes render
- dates render correctly, not as "Invalid Date" and not decades away
- reactions, edits, deletes, read state and unread counts update
- after dropping the network and reconnecting, the open channel catches up, and the offline banner shows and hides
- a thread's replies load and update live
- after logging out and in as another user, the lists and channels belong to the new user
- image and file uploads succeed, with progress