# 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:

```tsx
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

| 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 `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`](https://getstream.io/chat/docs/sdk/react/components/contexts/component-context/) slot:

```tsx
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:

```tsx
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.


---

This page was last updated at 2026-08-28T16:17:28.288Z.

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/guides/sending-messages-with-pending-uploads/](https://getstream.io/chat/docs/sdk/react/guides/sending-messages-with-pending-uploads/).