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-chat 9.52.0 or later.

Enabling It

Install createSendWithPendingUploadsAttachmentsMiddleware on every composer, through the composer setup function:

import { createSendWithPendingUploadsAttachmentsMiddleware } from "stream-chat";

client.setMessageComposerSetupFunction(({ composer }) => {
  composer.compositionMiddlewareExecutor.replace([
    createSendWithPendingUploadsAttachmentsMiddleware(composer),
  ]);
});

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

StepDefault behaviourWith the middleware installed
Send button and EnterDisabled while an upload is in flightLive — a pending attachment counts as content
Pressing sendNothing happens; a warning notification is raisedComposer clears immediately, so the user can start the next message
Message listThe message appears once the request completesThe message appears at once, in sending status, rendering the local file with progress
API requestSent immediatelySent after the message's uploads settle
Order of two messagesWhatever order the requests complete inSerialised 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 File handles cannot be serialised, so a refresh mid-upload orphans the optimistic message.
  • disconnectUser aborts 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>;
PropDescriptionType
attachmentThe attachment being uploaded. The component renders nothing unless its upload is in flight.Attachment | LocalAttachment
attachmentsSeveral attachments sharing one indicator, used by galleries. Their progress is averaged. Ignored when attachment is given.(Attachment | LocalAttachment)[]
classNameAdditional 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 valueTypeMeaningHow to use it
isUploadingbooleanA 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.
progressnumber | undefined0100 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.
uploadConfirmationPendingbooleanEvery 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.