Утечки памяти

Утечки памяти в приложениях с использованием STOMP.js чаще всего связаны не с самой библиотекой, а с неправильным управлением жизненным циклом WebSocket-соединений, подписок, таймеров и пользовательских обработчиков.

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

Наиболее распространённые причины:

  • повторное создание клиента без уничтожения предыдущего;
  • накопление подписок;
  • отсутствие unsubscribe;
  • хранение ссылок на DOM-элементы внутри callback-функций;
  • бесконечные reconnect-циклы;
  • создание таймеров без очистки;
  • утечки в React/Vue-компонентах;
  • накопление сообщений в памяти;
  • дублирование обработчиков событий.

Утечка из-за повторного создания STOMP-клиента

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

Проблемный код:

import { Client } from '@stomp/stompjs';

function connect() {
    const client = new Client({
        brokerURL: 'ws://localhost:15674/ws'
    });

    client.activate();
}

Если функция вызывается многократно:

setInterval(connect, 5000);

то память будет постоянно расти:

  • создаются новые WebSocket;
  • старые соединения остаются активными;
  • heartbeat-таймеры продолжают работать;
  • обработчики сообщений сохраняются в памяти.

Правильный подход:

import { Client } from '@stomp/stompjs';

let client = null;

function connect() {
    if (client && client.active) {
        return;
    }

    client = new Client({
        brokerURL: 'ws://localhost:15674/ws'
    });

    client.activate();
}

async function disconnect() {
    if (client) {
        await client.deactivate();
        client = null;
    }
}

Утечки подписок

Каждый вызов client.subscribe() создаёт объект подписки и callback-функцию.

Если подписки не удаляются:

client.subscribe('/topic/chat', message => {
    console.log(message.body);
});

то:

  • callback остаётся в памяти;
  • обработчики продолжают получать сообщения;
  • количество вызовов растёт;
  • увеличивается нагрузка на CPU.

Особенно опасна ситуация при повторном рендере компонентов.


Корректное удаление подписок

STOMP.js возвращает объект подписки:

const subscription = client.subscribe(
    '/topic/chat',
    message => {
        console.log(message.body);
    }
);

Удаление выполняется через:

subscription.unsubscribe();

Полный пример:

let subscription = null;

function start() {
    subscription = client.subscribe(
        '/topic/chat',
        onMessage
    );
}

function stop() {
    if (subscription) {
        subscription.unsubscribe();
        subscription = null;
    }
}

Утечки в React

В React наиболее частая проблема связана с useEffect.

Неправильный вариант:

useEffect(() => {
    const subscription = client.subscribe(
        '/topic/data',
        message => {
            setData(JSON.parse(message.body));
        }
    );
}, []);

Подписка никогда не удаляется после размонтирования компонента.

Правильный вариант:

useEffect(() => {
    const subscription = client.subscribe(
        '/topic/data',
        message => {
            setData(JSON.parse(message.body));
        }
    );

    return () => {
        subscription.unsubscribe();
    };
}, []);

Утечки из-за reconnect

Автоматический reconnect — полезный механизм, но при неправильной реализации он вызывает лавинообразный рост памяти.

Проблемный пример:

client.onWebSocketCl ose = () => {
    connect();
};

Каждое закрытие создаёт новый клиент:

  • старый объект остаётся в памяти;
  • heartbeat не останавливается;
  • reconnect-цепочки начинают дублироваться.

Правильный подход — использовать встроенный reconnect:

const client = new Client({
    brokerURL: 'ws://localhost:15674/ws',
    reconnectDelay: 5000
});

STOMP.js самостоятельно управляет повторным подключением.


Утечки heartbeat-таймеров

STOMP.js создаёт внутренние таймеры heartbeat.

Если соединение закрывается некорректно:

client.webSocket.close();

то heartbeat может остаться активным.

Корректное завершение:

await client.deactivate();

Метод:

  • очищает heartbeat;
  • удаляет reconnect-таймеры;
  • завершает WebSocket;
  • освобождает внутренние ссылки.

Накопление сообщений в памяти

Часто сообщения сохраняются в массив:

const messages = [];

client.subscribe('/topic/logs', message => {
    messages.push(message.body);
});

При большом потоке данных память будет бесконечно расти.

Особенно опасны:

  • логи;
  • телеметрия;
  • мониторинг;
  • биржевые данные;
  • игровые события.

Ограничение буфера:

const MAX_MESSAGES = 1000;

client.subscribe('/topic/logs', message => {
    messages.push(message.body);

    if (messages.length > MAX_MESSAGES) {
        messages.shift();
    }
});

Утечки через замыкания

Замыкания могут удерживать крупные объекты в памяти.

Проблемный пример:

const largeObject = {
    data: new Array(100000).fill('test')
};

client.subscribe('/topic/data', () => {
    console.log(largeObject.data.length);
});

Даже если largeObject больше не нужен, callback удерживает его.

Лучше хранить только необходимые данные:

const size = largeObject.data.length;

client.subscribe('/topic/data', () => {
    console.log(size);
});

Утечки DOM-элементов

Опасная ситуация:

const element = document.getElementById('logs');

client.subscribe('/topic/logs', message => {
    element.innerHTML += message.body;
});

Если DOM-элемент удалён:

  • callback всё ещё хранит ссылку;
  • браузер не освобождает память.

Безопаснее:

let element = document.getElementById('logs');

const subscription = client.subscribe(
    '/topic/logs',
    message => {
        if (element) {
            element.innerHTML += message.body;
        }
    }
);

function destroy() {
    subscription.unsubscribe();
    element = null;
}

Утечки при работе с Vue

Во Vue проблема аналогична React.

Неправильно:

mounted() {
    this.subscription = client.subscribe(
        '/topic/chat',
        this.onMessage
    );
}

Если компонент уничтожается, подписка остаётся.

Правильный вариант:

beforeUnmount() {
    if (this.subscription) {
        this.subscription.unsubscribe();
    }
}

Утечки в Angular

Angular-компоненты также требуют очистки подписок.

Пример:

export class ChatComponent implements OnDestroy {

    private subscription: StompSubscription;

    ngOnInit() {
        this.subscription = client.subscribe(
            '/topic/chat',
            message => {
                console.log(message.body);
            }
        );
    }

    ngOnDestroy() {
        this.subscription.unsubscribe();
    }
}

Проблемы с глобальными singleton-объектами

Иногда STOMP-клиент хранится глобально:

window.stompClient = client;

или:

globalThis.client = client;

Это препятствует сборке мусора.

Лучше использовать:

  • dependency injection;
  • context/provider;
  • service layer;
  • lifecycle management.

Утечки через пользовательские EventEmitter

Проблемный код:

eventBus.on('message', data => {
    console.log(data);
});

Если listener не удаляется:

eventBus.off('message', handler);

то:

  • listener накапливается;
  • callback остаётся в памяти;
  • увеличивается количество вызовов.

Утечки в системах уведомлений

Типичный пример:

notifications.push({
    id: Date.now(),
    text: message.body
});

Если уведомления не очищаются:

setInterval(() => {
    notifications.shift();
}, 5000);

массив становится бесконечным.


Утечки бинарных данных

При работе с бинарными сообщениями проблема становится особенно критичной.

Пример:

client.subscribe('/topic/binary', message => {
    const blob = new Blob([message.binaryBody]);

    cache.push(blob);
});

Blob-объекты занимают значительный объём памяти.

Необходимо:

  • удалять старые Blob;
  • очищать URL.createObjectURL;
  • ограничивать кеш.

Освобождение Object URL

Проблемный пример:

const url = URL.createObjectURL(blob);

image.src = url;

Если не вызвать:

URL.revokeObjectURL(url);

память браузера не освобождается.


Утечки из-за логирования

Отладочный режим может вызывать накопление строк.

Пример:

client.debug = str => {
    logs.push(str);
};

При большом потоке сообщений:

  • массив растёт;
  • строки занимают память;
  • GC работает всё чаще.

Безопаснее:

client.debug = () => {};

или ограничивать размер лога:

const MAX_LOGS = 500;

Анализ утечек в Chrome DevTools

Для поиска утечек используются:

  • Memory;
  • Heap Snapshot;
  • Allocation instrumentation;
  • Performance Monitor.

Типичная последовательность:

  1. Открыть вкладку Memory.
  2. Создать Heap Snapshot.
  3. Выполнить подключение/отключение.
  4. Повторно создать Snapshot.
  5. Сравнить retained objects.

Особое внимание уделяется:

  • WebSocket;
  • Closure;
  • Timer;
  • Array;
  • Detached DOM tree.

Проверка активных WebSocket

Во вкладке Network → WS можно увидеть:

  • количество соединений;
  • закрываются ли сокеты;
  • появляются ли дубликаты;
  • продолжаются ли heartbeat.

Если после закрытия страницы соединение остаётся активным — присутствует утечка.


Контроль таймеров

STOMP.js активно использует:

  • setInterval;
  • setTimeout;
  • reconnect timers;
  • heartbeat timers.

Проверка:

const interval = setInterval(() => {}, 1000);

clearInterval(interval);

Любой пользовательский таймер должен очищаться при destroy/unmount.


Централизованный менеджер соединений

Крупные приложения используют единый сервис:

class StompService {

    client = null;

    subscriptions = [];

    connect() {
        this.client = new Client({
            brokerURL: 'ws://localhost:15674/ws',
            reconnectDelay: 5000
        });

        this.client.activate();
    }

    subscribe(destination, callback) {
        const sub = this.client.subscribe(
            destination,
            callback
        );

        this.subscriptions.push(sub);

        return sub;
    }

    disconnect() {
        this.subscriptions.forEach(sub => {
            sub.unsubscribe();
        });

        this.subscriptions = [];

        return this.client.deactivate();
    }
}

Преимущества:

  • единый lifecycle;
  • централизованная очистка;
  • отсутствие дублирования;
  • упрощённый контроль памяти.

WeakMap и WeakRef

Для временных объектов можно использовать:

const cache = new WeakMap();

или:

const ref = new WeakRef(object);

Это снижает риск удержания объектов в памяти.


Типичные симптомы утечек памяти

Наиболее характерные признаки:

  • постепенное замедление интерфейса;
  • рост потребления RAM;
  • увеличение CPU usage;
  • зависания вкладки;
  • увеличение количества WebSocket;
  • повторные сообщения;
  • дублирование callback;
  • задержки GC;
  • crash браузера.

Практика безопасного управления памятью

Основные правила:

Один клиент — одно соединение

if (!client.active) {
    client.activate();
}

Всегда вызывать unsubscribe

subscription.unsubscribe();

Корректно завершать соединение

await client.deactivate();

Ограничивать буферы

messages.splice(0, messages.length - MAX);

Очищать таймеры

clearInterval(timer);

Не хранить DOM-ссылки

Лучше использовать:

  • refs;
  • reactive state;
  • virtual DOM.

Не создавать reconnect вручную

Использовать:

reconnectDelay: 5000

Контролировать объём логов

client.debug = () => {};

Удалять Object URL

URL.revokeObjectURL(url);

Использовать lifecycle hooks

  • React → cleanup function;
  • Vue → beforeUnmount;
  • Angular → ngOnDestroy.