import { createSendWithPendingUploadsAttachmentsMiddleware } from "stream-chat";
client.setMessageComposerSetupFunction(({ composer }) => {
composer.compositionMiddlewareExecutor.replace([
createSendWithPendingUploadsAttachmentsMiddleware(composer),
]);
});Sending Messages With Pending Uploads
By default, the composer refuses to send a message while any of its attachments is still uploading: the send button stays disabled and Enter does nothing until every upload finishes. By installing one composition middleware, the message is sent right away instead — it appears in the message list with live upload progress, and the API request is made once the uploads settle.
Best Practices
- Turn this on when users attach large files on slow connections, where waiting for the upload before typing the next message is the frustrating part.
- Leave it off if your app relies on a message being on the server the moment it appears in the list.
- Tell users what a still-uploading message means - nor present on the server, message-is-being-sent indicator. The default UI shows progress on the attachment; anything you customize should keep that signal.
- Test the failure path. One failed upload fails the whole message, and the user has to resend it.
- Requires
stream-chat9.52.0 or later.
Enabling It
Install createSendWithPendingUploadsAttachmentsMiddleware on every composer, through the composer setup function:
Call this before rendering Chat, or at least before the composers you care about are created — the setup function runs once per composer, when it is created. It reaches the channel, thread and edit composers alike, so there is nothing to repeat per view.
The middleware replaces the default attachments composition middleware (createAttachmentsCompositionMiddleware), which the composer installs itself. Both share the middleware id stream-io/message-composer-middleware/attachments, so replace keeps its position in the chain.
Installing it is the whole switch. The composer reports allowsPendingUploads, which is how it relaxes its own sendability rule and how this SDK's send path knows to await the uploads — there is no prop or config option to keep in sync with it. Nothing else in your integration has to change, including custom Attachment components, which receive the still-uploading attachment like any other.
What Changes
| Step | Default behaviour | With the middleware installed |
|---|---|---|
| Send button and Enter | Disabled while an upload is in flight | Live — a pending attachment counts as content |
| Pressing send | Nothing happens; a warning notification is raised | Composer clears immediately, so the user can start the next message |
| Message list | The message appears once the request completes | The message appears at once, in sending status, rendering the local file with progress |
| API request | Sent immediately | Sent after the message's uploads settle |
| Order of two messages | Whatever order the requests complete in | Serialised per channel, so a text-only message cannot overtake an earlier upload. Messages sent in order to the server. |
Attachments that will never resolve on their own — failed and blocked uploads — still do not count as sendable content, so a message whose only attachment was rejected cannot be sent.
Upload Progress In The Message List
A message with an attachment that is still uploading renders from the user's local file and shows progress in place of the file size. This is handled by the AttachmentUploadProgressIndicator component, which the file, audio, voice recording, image, video and gallery widgets render for you.
The indicator reuses the same UI as the composer's attachment previews, so an attachment looks the same on both sides of pressing send:
- a progress ring while the transfer length is known,
- an indeterminate spinner once every byte has been sent but the server has not confirmed the upload yet,
- the uploaded-vs-total byte count next to it.
This UI is opt-in by construction: it only renders while client.uploadManager holds a live record for the attachment, which is never the case for attachments returned by the API. It also self-heals — if an upload is aborted, or disconnectUser resets the upload manager, the progress stops rather than leaving a spinner behind forever.
Video and audio attachments stay playable from the local file while the upload runs.
Cancelling An Upload
The remove button on an attachment preview stays active while the upload is in flight, labelled "Cancel upload". Removing the attachment aborts the request through its AbortController and releases its local preview.
Cancelling is not reported as an upload failure.
Failure And Retry
If any upload for a message fails, the whole message fails: no request is made, and the message is marked failed with an error, so the standard failed-message actions (resend, edit, delete) appear.
Resending re-uploads only what did not make it. Uploads that succeeded keep the URLs they resolved to, so a retry does not re-send bytes that already reached the CDN.
Limitations
- A page reload loses in-flight messages. The browser's
Filehandles cannot be serialised, so a refresh mid-upload orphans the optimistic message. disconnectUseraborts running uploads, which marks the affected messages failed.- Editing a message whose upload is still running is not supported. The edit composer mints new local attachment ids and does not carry the file handle.
Customizing The Progress UI
AttachmentUploadProgressIndicator is a ComponentContext slot:
import { WithComponents } from "stream-chat-react";
import type { AttachmentUploadProgressIndicatorProps } from "stream-chat-react";
const CustomUploadProgress = ({
attachment,
variant,
}: AttachmentUploadProgressIndicatorProps) => {
// ...
};
<WithComponents
overrides={{ AttachmentUploadProgressIndicator: CustomUploadProgress }}
>
<MessageList />
</WithComponents>;| Prop | Description | Type |
|---|---|---|
attachment | The attachment being uploaded. The component renders nothing unless its upload is in flight. | Attachment | LocalAttachment |
attachments | Several attachments sharing one indicator, used by galleries. Their progress is averaged. Ignored when attachment is given. | (Attachment | LocalAttachment)[] |
className | Additional class names. | string |
variant | "inline" sits in the row that normally holds the file size; "overlay" is positioned over the media itself. | "inline" | "overlay" |
Reading Upload State In Your Own Component
useAttachmentUploadState(attachment) reports whether a request for that attachment is in flight right now, and how far along it is. Its source of truth is client.uploadManager, which holds a record only for the lifetime of the request — not the attachment's localMetadata, which is a frozen snapshot on a message.
| Returned value | Type | Meaning | How to use it |
|---|---|---|---|
isUploading | boolean | A request for this attachment is running at this moment. | Render your progress UI only while this is true; everything else is meaningless when it is false. |
progress | number | undefined | 0–100 while the transfer length is known; undefined when it is not. | Drive a determinate bar or ring when it is a number, and an indeterminate one when it is undefined. |
uploadConfirmationPending | boolean | Every byte has been written to the connection, but the server has not answered yet. | Switch to an indeterminate indicator — a bar parked at 100% claims the upload is finished when it is not. |
progress reaches 100% the instant the request body is flushed, because browser upload progress measures bytes written to the connection, not bytes the server acknowledged. The connection then sits idle while the CDN ingests the file and the response travels back — a long dead zone for a large file on a slow link. uploadConfirmationPending marks exactly that window, so the two are read together:
import { useAttachmentUploadState } from "stream-chat-react";
const MyUploadProgress = ({ attachment }) => {
const { isUploading, progress, uploadConfirmationPending } =
useAttachmentUploadState(attachment);
if (!isUploading) return null;
const indeterminate = uploadConfirmationPending || progress === undefined;
return indeterminate ? <Spinner /> : <ProgressRing value={progress} />;
};Because isUploading follows the live record, the UI is self-healing: if the upload is aborted, or disconnectUser resets the upload manager, it flips to false rather than leaving a spinner behind forever. It is also false for every attachment that came back from the API, so this UI cannot appear on a message the server already stored.
For a gallery, useAttachmentsUploadState(attachments) takes several attachments and returns one combined state: isUploading is true while any of them is in flight, and progress is their mean — reported only once every pending upload has a number, so the indicator does not flip between determinate and indeterminate mid-flight.