Кеширование часто читаемых значений в памяти

При работе с localForage ключевым ограничением становится разница в производительности между оперативной памятью и постоянным хранилищем браузера (IndexedDB, WebSQL, localStorage). Даже при асинхронной природе API частые обращения к дисковому уровню создают ощутимые задержки, особенно в сценариях с высокой частотой чтения одних и тех же значений.

Именно поэтому поверх хранилища почти всегда вводится дополнительный слой — in-memory кеш, который уменьшает количество обращений к диску и стабилизирует латентность операций чтения.


Причины появления дублирующего кеша

Базовое поведение localForage предполагает асинхронное чтение:

await localforage.getItem('profile');

Однако даже асинхронный вызов:

  • требует обращения к IndexedDB транзакции
  • проходит сериализацию/десериализацию
  • зависит от состояния event loop и блокировок
  • может быть значительно медленнее памяти (особенно на мобильных устройствах)

При повторных чтениях одного и того же ключа это превращается в избыточную нагрузку.


Базовая модель memory-cache поверх localForage

Наиболее простая схема строится на использовании Map:

import localforage fr om "localforage";

const memoryCache = new Map();

async function get(key) {
  if (memoryCache.has(key)) {
    return memoryCache.get(key);
  }

  const value = await localforage.getItem(key);
  memoryCache.set(key, value);

  return value;
}

В этой модели:

  • первый вызов идёт в IndexedDB
  • последующие — полностью из памяти
  • снижение нагрузки пропорционально числу повторных обращений

Инвалидация и синхронизация состояния

Основная проблема кеширования — рассинхронизация памяти и хранилища.

Любая операция записи должна обновлять оба уровня:

async function set(key, value) {
  memoryCache.set(key, value);
  await localforage.setItem(key, value);
}

async function remove(key) {
  memoryCache.delete(key);
  await localforage.removeItem(key);
}

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


TTL-кеширование (time-to-live)

Статические кеши быстро устаревают в реальных приложениях. Добавление TTL решает проблему устаревших данных:

const memoryCache = new Map();

function setCache(key, value, ttlMs = 60000) {
  const expiresAt = Date.now() + ttlMs;

  memoryCache.set(key, {
    value,
    expiresAt
  });
}

function getCache(key) {
  const entry = memoryCache.get(key);

  if (!entry) return null;
  if (Date.now() > entry.expiresAt) {
    memoryCache.delete(key);
    return null;
  }

  return entry.value;
}

Интеграция с localForage:

async function get(key) {
  const cached = getCache(key);
  if (cached !== null) return cached;

  const value = await localforage.getItem(key);
  setCache(key, value, 60000);

  return value;
}

Stale-while-revalidate стратегия

Более продвинутая модель сочетает скорость памяти и актуальность диска.

Принцип:

  • сначала возвращается кешированное значение
  • параллельно запускается обновление из storage
async function get(key) {
  const cached = memoryCache.get(key);

  if (cached) {
    // фоновое обновление
    localforage.getItem(key).then(value => {
      memoryCache.set(key, value);
    });

    return cached;
  }

  const value = await localforage.getItem(key);
  memoryCache.set(key, value);

  return value;
}

Такая модель особенно эффективна для:

  • пользовательских профилей
  • настроек интерфейса
  • справочных данных

Ограничение размера кеша (LRU-подход)

Без контроля размера память становится неконтролируемым накопителем данных. Используется LRU (Least Recently Used):

class LRUCache {
  constructor(lim it = 100) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return null;

    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);

    return value;
  }

  set(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    } else if (this.map.size >= this.limit) {
      const oldestKey = this.map.keys().next().value;
      this.map.delete(oldestKey);
    }

    this.map.set(key, value);
  }
}

Интеграция с localForage позволяет удерживать баланс между скоростью и памятью.


Кеширование структурированных данных

При хранении сложных объектов возникает дополнительная проблема — частичное обновление.

Плохой подход:

  • перезапись всего объекта при любом изменении
  • инвалидирует весь кеш

Оптимизированная стратегия:

  • нормализация данных
  • кеширование по сущностям
// users:123, users:124 вместо usersList
const getUser = async (id) => {
  const key = `users:${id}`;

  if (memoryCache.has(key)) {
    return memoryCache.get(key);
  }

  const user = await localforage.getItem(key);
  memoryCache.set(key, user);

  return user;
};

Защита от “cache stampede”

При одновременном обращении к одному ключу возможна ситуация множественных одинаковых запросов в storage.

Решение — кеширование промисов:

const pending = new Map();

async function get(key) {
  if (memoryCache.has(key)) {
    return memoryCache.get(key);
  }

  if (pending.has(key)) {
    return pending.get(key);
  }

  const promise = localforage.getItem(key).then(value => {
    memoryCache.set(key, value);
    pending.delete(key);
    return value;
  });

  pending.set(key, promise);

  return promise;
}

Такой подход снижает нагрузку на IndexedDB при параллельных чтениях.


Согласованность при конкурентных записях

При одновременных setItem возможны состояния гонки. Для стабилизации применяется последовательная очередь:

let queue = Promise.resolve();

function set(key, value) {
  queue = queue.then(async () => {
    memoryCache.set(key, value);
    await localforage.setItem(key, value);
  });

  return queue;
}

Это предотвращает перетирание данных при высокой частоте записей.


Гибридная модель кеширования

На практике используется комбинация механизмов:

  • in-memory Map или LRU
  • TTL для устаревания
  • stale-while-revalidate для UX
  • pending-map для дедупликации запросов
  • очередь для сериализации записей

Такая архитектура превращает localForage из простого storage API в основу высокопроизводительного слоя данных в браузере.


Особенности поведения при перезагрузке страницы

Память очищается при каждом reload, поэтому:

  • кеш не должен считаться источником истины
  • восстановление всегда начинается с storage
  • warming cache может выполняться лениво или при старте приложения

Ленивая стратегия предпочтительнее:

  • минимизирует стартовую нагрузку
  • распределяет I/O во времени

Типовые ошибки реализации кеша

Часто встречаются следующие проблемы:

  • кеширование undefined как валидного значения
  • отсутствие инвалидции при удалении
  • смешивание разных типов данных под одним ключом
  • отсутствие ограничения размера кеша
  • неконтролируемый рост pending-структур

Каждая из этих ошибок приводит к деградации консистентности данных или утечкам памяти.


Эффект от внедрения memory-cache слоя

При корректной реализации:

  • количество обращений к IndexedDB снижается в разы
  • улучшается отклик интерфейса
  • уменьшается jitter при частых чтениях
  • стабилизируется нагрузка на main thread

Поверх localForage такой слой становится стандартной практикой построения клиентских data-layer архитектур.