Передаём большой файл через WebSocket

При передаче большого файла расход памяти зависит от того, насколько чтение опережает запись у получателя. Даже фрагменты по 64 КиБ могут накопиться на целый файл, если беспрерывно готовить следующие части. Объём очереди можно ограничить, связав новое чтение с подтверждением записи у получателя. Тогда медленный получатель заставит отправителя подождать.

Учебные примеры подготовлены в октябре 2026 года. В них использованы современные возможности JavaScript и платформы; архивная дата не относится к этим реализациям.

Вызов WebSocket.send() позволяет передать данные на отправку и продолжить работу. С файлом хочется сделать то же самое несколько тысяч раз, а после последнего вызова показать «Готово». Только send() не ждёт ни доставки, ни записи на другом конце. Пока цикл читает следующие фрагменты, предыдущие могут оставаться в памяти.

Для сравнения я собрал учебный пример, в котором отправитель и получатель работают на одной странице и заранее знают размер файла. Сервер пересылает сообщения между ними, а получатель пишет данные во временное хранилище браузера. Медленная подготовка и запись помогут увидеть, где растут очереди и какие ограничения позволяют сдержать их рост.

Ниже приведены части кода, которые управляют чтением, очередями и записью. В отдельном приложении получателю пришлось бы узнать и проверить размер до начала приёма. Шифрования и возобновления здесь нет: при обрыве попытка заканчивается, неполный файл удаляется. Для сравнения достаточно исходника до 16 МиБ; очереди будем ограничивать независимо от его длины.

Маленький фрагмент внутри большого буфера

Начнём с решения, которое выглядит вполне аккуратно:

const size = 64 * 1024;
const bytes = await file.arrayBuffer();

for (let offset = 0; offset < bytes.byteLength; offset += size) {
  socket.send(bytes.slice(offset, offset + size));
}

Уже на второй строке мы прочитали файл целиком. Последующее разбиение не отменяет эту работу. Если исходник весит гигабайт, маленькие вызовы send() не превращают его в маленький буфер.

Кроме того, цикл не спрашивает, успевает ли соединение отправлять данные. Вызов send() ставит их на отправку, а количество ещё не переданных байтов доступно через bufferedAmount. Это локальный показатель. Ноль в нём не означает, что получатель сохранил файл. Такое поведение определено в интерфейсе WebSocket.

Можно убрать чтение целиком:

for (let offset = 0; offset < file.size; offset += size) {
  const bytes = await file.slice(offset, offset + size).arrayBuffer();
  socket.send(bytes);
}

Теперь каждый arrayBuffer() относится только к одному фрагменту. Это полезное изменение, но скорость чтения всё ещё не связана со скоростью отправки. Если диск выдаёт данные быстрее сети, мы просто переносим очередь из своего массива внутрь сокета.

Я бы сначала нарисовал путь одного фрагмента. Тогда будет видно, что именно мы собираемся ограничивать:

Файл → чтение и подготовка в воркере → передача в основной поток
                                             ↓
                                  буфер WebSocket.send()
                                             ↓
                              ретранслятор и второе соединение
                                             ↓
                                  очередь записи получателя
                                             ↓
                                      временный файл

Каждый этап может работать со своей скоростью. Если ограничить очередь отправки, это ещё не помешает данным копиться перед записью.

Воркер не должен работать про запас

Перенос чтения в воркер освобождает основной поток от подготовки фрагментов. Но если воркер без остановки читает файл и вызывает postMessage(), у нас появляется ещё одно место, где данные могут ждать обработки. Интерфейс стал отзывчивее, а расход памяти продолжает расти.

В нашем варианте воркер готовит один фрагмент по запросу. Основной поток присылает read с номером, воркер возвращает результат и ждёт следующего запроса. Так чтение не опережает основной поток: без нового read воркер не берётся за следующий фрагмент.

К байтам файла добавлены номер и хеш содержимого. Номер занимает четыре байта, SHA-256 ещё 32. Для последнего фрагмента размер данных может быть меньше 64 КиБ.

const chunkSize = 64 * 1024;
const headerSize = 36;

async function frame(index, bytes) {
  const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
  const data = new Uint8Array(headerSize + bytes.byteLength);
  new DataView(data.buffer).setUint32(0, index);
  data.set(hash, 4);
  data.set(new Uint8Array(bytes), headerSize);
  return data.buffer;
}

В обработчике воркера остаётся короткая последовательность:

const start = index * chunkSize;
const bytes = await file.slice(start, start + chunkSize).arrayBuffer();
const data = await frame(index, bytes);
self.postMessage({ kind: 'chunk', index, data }, [data]);

Последний аргумент передаёт владение ArrayBuffer в другой поток. После этого воркер больше не может пользоваться его содержимым. Само создание заголовка всё равно потребовало нового массива, а вычисление хеша может использовать внутренние буферы. Передача владения не даёт основания обещать отсутствие любых копий.

На стороне основного потока reader.read(index, signal) выдаёт очередное разрешение и возвращает промис результата. Второе чтение до завершения первого считается ошибкой. Обработчики результата снимаются и при ответе, и при отмене; завершение попытки вызывает worker.terminate(). Так у нас не остаётся фонового производителя, который продолжает готовить уже никому не нужный файл.

Если подготовка окажется медленной, можно разрешить воркеру читать несколько фрагментов заранее. Но сначала стоит измерить, сколько времени мы теряем на ожидании. Пока одного фрагмента достаточно, а объём прочитанных данных проще контролировать.

Ждём, пока сокет освободит место

Для локального буфера зададим предел 256 КиБ. К текущему bufferedAmount прибавляем размер сообщения, которое собираемся добавить:

const bufferLimit = 256 * 1024;

await until(
  () => socket.bufferedAmount + data.byteLength <= bufferLimit,
  signal
);

send(socket, data, signal);

Проверка только текущего размера буфера разрешала бы добавить фрагмент, который уже не помещается. Поэтому учитываем и его длину.

В классическом браузерном WebSocket нет события, которое сообщает, что буфер опустел до выбранной нами границы. Для примера достаточно короткого опроса. Ожидание должно завершаться при отмене, а отправка повторно проверять соединение:

function pause(ms, signal) {
  signal.throwIfAborted();

  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      signal.removeEventListener('abort', abort);
      resolve();
    }, ms);

    function abort() {
      clearTimeout(timer);
      reject(signal.reason);
    }

    signal.addEventListener('abort', abort, { once: true });
  });
}

async function until(condition, signal) {
  while (!condition()) {
    await pause(5, signal);
  }

  signal.throwIfAborted();
}

function send(socket, data, signal) {
  signal.throwIfAborted();

  if (socket.readyState !== WebSocket.OPEN) {
    throw new Error('Соединение закрыто');
  }

  socket.send(data);
}

Таймер может сработать позже пяти миллисекунд, например в фоновой вкладке. Передача тогда замедлится, но проверка размера буфера всё равно выполнится перед отправкой.

Если другая сторона перестала отвечать, опрос нужно остановить. В демонстрации сигнал объединяет отмену пользователя с ошибкой соединения, а вся попытка ограничена 30 секундами. Этого хватает для небольшого учебного файла. Для настоящей передачи лучше отдельно определить, сколько ждать следующего подтверждения, с учётом скорости сети и устройств.

Получатель всё ещё может не успевать

Теперь отправитель читает понемногу и следит за сокетом. На быстрой сети его локальный буфер почти всегда пуст. Значит ли это, что всё хорошо?

Пусть получатель тратит 20 мс на обработку и запись каждого фрагмента. WebSocket доставляет сообщения быстрее. Если обработчик message запускает асинхронную запись и сразу возвращается, следующие события продолжают поступать. А если мы выстроили записи цепочкой промисов, ожидающие продолжения удерживают свои данные в памяти.

Сначала ограничим очередь на стороне получателя. Перед приёмом задаём сокету binaryType = 'arraybuffer', чтобы бинарные сообщения приходили как ArrayBuffer, а не как Blob. Выбор формата описан в стандарте WebSocket. Текстовые служебные сообщения разбираем отдельно; следующий фрагмент относится только к обработке бинарных данных.

В счётчик pending включим и ожидающие фрагменты, и тот, который уже записывается. Перед началом он равен нулю, а queue — пустой массив:

if (pending >= limit) {
  throw new Error('Очередь записи переполнена');
}

queue.push(event.data);
pending += 1;

После успешной записи pending уменьшается. Если уменьшить его сразу после queue.shift(), число перестанет учитывать фрагмент, занятый записью. Оба определения допустимы, но тогда пределы означают разное. В этом примере я считаю всю ещё не записанную работу.

Теперь наша очередь ограничена, но данные всё ещё могут ждать во внутренних буферах браузера до события message. Сам лимит очереди не заставляет отправителя замедлиться: передача может оборваться на исправном медленном устройстве. Нужно сообщить отправителю, что получатель не успевает записывать данные, до переполнения и дать возможность подождать.

Разрешение приходит после записи

Введём окно из восьми неподтверждённых фрагментов. Отправитель знает, сколько он отправил (sent) и сколько получатель подтвердил (acknowledged). Оба счётчика начинают с нуля. Пока разница меньше восьми, можно читать следующий фрагмент. Как только окно заполнено, чтение останавливается. В этом и следующих фрагментах size означает полный размер файла в байтах, а chunkSize — размер его части.

const windowSize = 8;
const total = Math.ceil(size / chunkSize);

for (let index = 0; index < total; index += 1) {
  await until(() => sent - acknowledged < windowSize, signal);

  const data = await reader.read(index, signal);
  signal.throwIfAborted();

  await until(
    () => socket.bufferedAmount + data.byteLength <= bufferLimit,
    signal
  );

  send(socket, data, signal);
  sent += 1;
}

Здесь существенно место первого ожидания. Оно стоит до reader.read(). Если сначала прочитать весь файл, а потом ждать разрешений на отправку, ограничение сети не избавит нас от уже накопленных буферов.

Получатель обрабатывает очередь последовательно: проверяет номер и длину фрагмента, затем сверяет хеш и записывает данные. Число записанных фрагментов written тоже начинается с нуля. Только после выполнения write() получатель отправляет подтверждение:

const data = queue.shift();
const length = Math.min(chunkSize, size - written * chunkSize);
const bytes = await verify(data, written, length);
signal.throwIfAborted();

await storage.write(bytes);
signal.throwIfAborted();

written += 1;
pending -= 1;
send(socket, JSON.stringify({ kind: 'ack', next: written }), signal);

next обозначает число последовательно записанных фрагментов. Например, next: 5 подтверждает номера от 0 до 4. Подтверждение означает завершение операции записи в выбранное временное хранилище, но ещё не закрытие целого файла и не гарантию сохранности при сбое питания.

Если отправлять ack сразу из обработчика message, окно ограничит только доставку до этого обработчика. Записи снова смогут отставать, а отправитель будет получать разрешения на новые данные. Внешне протокол почти не изменится, но исчезнет именно та гарантия, ради которой мы его строили.

Предел очереди у получателя оставим: другой отправитель может не соблюдать окно. Принимающий код проверяет число поступивших фрагментов, отвергает данные после завершения и номер, который не совпадает с ожидаемым. Окно помогает согласовать скорость работы, а эти проверки обнаруживают нарушения протокола.

Подтверждения нельзя просто прибавлять

Можно было бы считать каждое сообщение ack новым разрешением. Но тогда повтор уже полученного подтверждения расширит окно. Поздний ответ после переподключения способен сделать то же самое.

Поэтому в подтверждении передаём общее число записанных фрагментов. Отправитель принимает только целое число в пределах уже отправленного, причём значение acknowledged может только расти:

function acceptAck(next, acknowledged, sent) {
  if (!Number.isSafeInteger(next) || next < 0 || next > sent) {
    throw new Error('Неверное подтверждение');
  }

  return Math.max(acknowledged, next);
}

Если уже подтверждено четыре фрагмента, повтор 4 ничего не меняет. Запоздавшее 2 тоже. Значение 9 при восьми отправленных фрагментах завершает попытку ошибкой. Мы не можем подтвердить то, чего ещё не отправляли.

В этой демонстрации одна пара соединений принадлежит одной попытке передачи. Новая попытка получает новые сокеты, поэтому подтверждение старого файла не может пройти через обработчик нового. Если несколько файлов идут через общее соединение, к сообщениям понадобится добавить идентификатор передачи и проверять его раньше номера. Одного счётчика в таком протоколе недостаточно.

Проверяем именно полученный фрагмент

Ожидаемый номер помогает заметить пропуск или повтор. Проверка длины обнаруживает обрезанный последний кусок и не позволяет незаметно дописать лишние данные. Хеш проверяет содержимое:

async function verify(data, index, length) {
  if (!(data instanceof ArrayBuffer) || data.byteLength !== length + headerSize) {
    throw new Error('Неверный размер фрагмента');
  }

  if (new DataView(data).getUint32(0) !== index) {
    throw new Error('Нарушен порядок фрагментов');
  }

  const bytes = new Uint8Array(data, headerSize);
  const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
  const expected = new Uint8Array(data, 4, 32);

  if (!hash.every((value, offset) => value === expected[offset])) {
    throw new Error('Фрагмент повреждён');
  }

  return bytes;
}

Хеш позволяет обнаружить повреждение байтов. От подмены он здесь не защищает: отправитель передаёт и данные, и хеш, а тот, кто способен заменить оба, пройдёт проверку. Для подтверждения подлинности файла нужен другой механизм.

Хешируем каждый небольшой фрагмент отдельно. Вызов crypto.subtle.digest() для всего файла потребовал бы сначала предоставить ему все байты; этой операцией можно вернуть в программу то самое чтение целиком, от которого мы избавлялись. Если протоколу нужен один итоговый хеш большого файла, потребуется потоковая реализация хеширования или другое устройство проверки. В данном примере достаточно проверенных фрагментов с последовательными номерами и известного общего размера.

Последний фрагмент ещё не завершает файл

Выход из цикла отправки говорит только о том, что все данные переданы в сокет. Часть фрагментов к этому моменту уже может быть записана. Отправитель дожидается подтверждения последнего фрагмента, затем отправляет отдельное сообщение:

await until(() => acknowledged === total, signal);
send(socket, JSON.stringify({ kind: 'finish', count: total }), signal);

Получатель принимает finish только один раз, после поступления ожидаемого числа фрагментов. Он заканчивает запись, закрывает файл и сверяет его размер. Только после этого возвращает done.

На каждом этапе «готово» означает разное:

Событие Что уже известно
Воркер подготовил последний фрагмент Исходник дочитан
Последний send() вернулся Данные приняты локальным API на отправку
Пришло подтверждение ack с next: total Получатель закончил операции записи всех фрагментов
Пришло done Получатель закрыл файл и проверил итоговый размер

В интерфейсе я показываю успех по последней строке. Если done потерялось вместе с соединением, отправитель не знает, чем закончилась попытка у получателя. Он не должен заменять это незнание успехом по таймеру. В полноценном сервисе судьбу такого файла можно уточнять отдельным запросом; наш учебный протокол этого не умеет и сообщает об обрыве.

Пустой файл проходит тот же путь без бинарных фрагментов: готовность получателя, finish с нулём, закрытие пустой записи и done. Для него не нужен придуманный «последний кусок» длиной ноль.

Пишем на диск, а не собираем новый массив

Если получатель сохраняет все полученные ArrayBuffer в массиве chunks, а в конце делает new Blob(chunks), ограниченное окно не ограничит размер накопленного результата. Очередь обработки может быть маленькой, но массив будет удерживать буферы на целый файл.

В демонстрации я использую OPFS, файловое хранилище, привязанное к источнику страницы (origin). Источник определяется протоколом и хостом, а также портом. Получатель создаёт временный файл и пишет в него последовательно:

const directory = await navigator.storage.getDirectory();
const handle = await directory.getFileHandle(name, { create: true });
const writable = await handle.createWritable();

await writable.write(bytes);

Последняя строка выполняется для каждого проверенного фрагмента. При завершении:

await writable.close();
const file = await handle.getFile();

if (file.size !== size) {
  throw new Error('Размер записанного файла не совпадает');
}

Закрытие записи здесь существенно: именно после него изменения становятся доступны через файл. Работа дескрипторов и потоков записи описана в File System Standard. Успешный write() не заменяет close().

Чтобы предложить скачивание, не нужно снова вызывать arrayBuffer():

const url = URL.createObjectURL(file);
download.href = url;
download.download = 'example.bin';
download.hidden = false;

Ссылка использует полученный File. Пока пользователь может ею воспользоваться, файл остаётся доступен. При следующей передаче демонстрация отзывает URL и удаляет предыдущий результат из OPFS. Автоматически удалять его сразу после создания ссылки было бы преждевременно.

Доступное дисковое место тоже не бесконечно. Создание и запись файла могут завершиться ошибкой, в том числе из-за квоты. Такая ошибка останавливает передачу и запускает удаление временных данных. Если OPFS недоступен, пример сообщает об этом до начала передачи. Молчаливо переходить к хранению всего файла в массиве означало бы отказаться от выбранного ограничения памяти, не сообщив об этом пользователю.

Отменяем одну передачу, не задевая следующую

Кнопка «Отменить» может быть нажата, когда воркер ещё читает исходник или получатель ждёт окончания write(). Уже начавшаяся операция не обязана исчезнуть вместе с изменением флага в интерфейсе. После await она продолжится и попробует отправить подтверждение или показать ссылку.

Поэтому каждая попытка получает собственный AbortController. Ожидания подписываются на его сигнал, а после операций, которые сами не поддерживают отмену, код проверяет его ещё раз. Это видно в цикле приёма: проверка стоит после вычисления хеша и после записи. Отменённая операция не выдаёт новое разрешение отправителю.

Очистку я тоже связываю с попыткой. У неё свои сокеты и воркер. Для временных файлов создан отдельный каталог OPFS. При ошибке одной стороны отменяется вторая, затем программа дожидается окончания обеих задач и освобождает ресурсы. Незавершённый поток записи прерывается, временный каталог удаляется. Ошибку самой очистки тоже следует сохранить для разбора.

Общий флаг active здесь недостаточен. Отменили A, начали B, и он снова стал истинным. Поздний результат A пройдёт эту проверку и может заменить состояние интерфейса. Поэтому сохраняем ссылку на текущую попытку и сверяем её после ожидания:

const operation = { controller: new AbortController() };
active = operation;

const result = await runDemo({ signal: operation.controller.signal });

if (active !== operation) {
  await result.cleanup();
  return;
}

status.textContent = 'Получатель закрыл и проверил файл';

Такая же проверка стоит в обработчике ошибки и в finally, который меняет состояние кнопок. Очистка старой попытки может закончиться позже новой, но уже не изменит её статус и кнопки.

Что меняют ограничения

Разницу между режимами можно выразить через пределы, заданные в коде. Возьмём учебный файл размером 8 МиБ: это 128 фрагментов по 64 КиБ. С заголовком каждое сообщение занимает 65 572 байта. Следующая таблица показывает ограничения алгоритма, а не результаты измерения памяти или скорости браузера.

Режим Что ограничивает чтение исходника Что происходит у медленного получателя
Чтение целиком, без ожидания Ничего: в памяти сразу весь файл Для сравнения очередь разрешена на весь учебный файл — до 128 фрагментов
Воркер и ограничение сокета Следующее сообщение должно поместиться в локальный буфер 256 КиБ Если восемь фрагментов ещё не записаны, следующий отвергается
Воркер, ограничение сокета и окно 8 Место нужно и в локальном буфере, и в окне подтверждений Отправитель ждёт записи, прежде чем читать следующий фрагмент

В первой строке только исходный arrayBuffer() занимает 8 МиБ. Отдельно данные могут ждать отправки и обработки. Все 128 сообщений с заголовками составляют 8 393 216 байт. Это возможный объём накопленных сообщений, а не измеренный максимум bufferedAmount. Складывать его с размером исходника и объявлять результат точным расходом памяти вкладки нельзя: часть операций создаёт дополнительные копии, а данные могут покидать очереди ещё во время чтения.

Во второй строке отправитель всё ещё способен переполнить приёмную очередь: его сокет может освобождаться быстрее, чем получатель записывает файл. В третьей он получает возможность ждать. В окне помещается не более восьми фрагментов по 65 572 байта с заголовками. Их суммарный размер равен 524 576 байтам; внутренние буферы и копии в этот расчёт не входят.

Ожидание сокета и ожидание подтверждения стоит проверять отдельно. На быстром локальном соединении маленькое окно может остановить чтение раньше, чем заполнится буфер сокета. Для проверки именно этого буфера в примере предусмотрен промежуточный режим без окна и временная остановка чтения TCP-сокета у ретранслятора. В таком опыте приёмную очередь разрешают на весь небольшой файл, чтобы после возобновления чтения не смешивать два разных ограничения.

Например, если bufferedAmount равен 200 КиБ, очередное сообщение уже не помещается: 204 800 + 65 572 = 270 372 байта, больше предела 262 144. Пока буфер не уменьшится, число отправленных и подготовленных фрагментов должно оставаться прежним. После освобождения места передача должна продолжиться, а при отмене — завершиться ошибкой, не отправив ожидающий фрагмент. Точный размер буфера и длительность ожидания зависят от соединения и реализации; из этих расчётов они не следуют.

На настоящей сети окно влияет и на скорость: если оно слишком маленькое для времени доставки подтверждения, отправитель будет простаивать. Увеличение окна позволяет иметь больше данных в пути, одновременно увеличивая допустимый объём ожидания. Значение восемь выбрано для наглядной демонстрации; обоснование рабочего размера потребует измерений на ожидаемых соединениях и устройствах.

Медленная подготовка в воркере меняет место ожидания. Если получатель успевает записать и подтвердить фрагмент до подготовки следующего, очередь не растёт, а отправитель ждёт чтения. Это тоже стоит проверять отдельно от медленной записи.

Для проверки границ нужны пустой файл, файл в один байт и файл длиной 65 537 байт — последний проверяет фрагмент из одного байта после полного. При отмене или обрыве не должно появляться сообщение об успехе, а временные данные должны удаляться после завершения задач. Повреждённое содержимое, неправильный номер и неверная длина должны отвергаться; повтор подтверждения не должен расширять окно.

Замена медленной передачи A новой передачей B — отдельный сценарий. Интерфейс должен показывать результат B, а очистка A не должна удалять готовый файл B или менять её статус. Корректной реакции на одиночную отмену ещё недостаточно, чтобы доверять общему состоянию интерфейса.

Теперь размер фрагмента можно выбирать по удобству чтения и передачи: расход памяти не зависит от него одного. Число ожидающих фрагментов ограничено, а завершение записи у получателя разрешает прочитать следующую часть. Если устройство работает медленно, отправитель ждёт. Именно этого не хватало первому циклу с send().