JavaScript

JavaScript

Tutorial JavaScript: LocalStorage y SessionStorage

Descubre los límites y seguridad en el almacenamiento web para aplicaciones modernas. Aprende a usar APIs eficientemente con medidas de protección.

Aprende JavaScript y certifícate

Límites y consideraciones de seguridad

Al trabajar con las APIs de almacenamiento web (localStorage y sessionStorage), es fundamental comprender sus limitaciones técnicas y las implicaciones de seguridad que conllevan. Estos mecanismos, aunque útiles, no son soluciones universales para todos los escenarios de persistencia de datos.

Capacidad de almacenamiento

La capacidad de almacenamiento disponible en localStorage y sessionStorage está limitada por los navegadores. Aunque esta limitación varía según el navegador, generalmente se establece alrededor de 5-10 MB por dominio. Esta restricción es importante cuando planificamos qué datos almacenar:

// Función para comprobar el espacio utilizado en localStorage
function checkStorageUsage() {
  let totalSize = 0;
  for (let key in localStorage) {
    if (localStorage.hasOwnProperty(key)) {
      totalSize += localStorage[key].length * 2; // Caracteres * 2 bytes (UTF-16)
    }
  }
  return (totalSize / 1024).toFixed(2) + " KB";
}

console.log(`Espacio utilizado: ${checkStorageUsage()}`);

Cuando intentamos exceder este límite, el navegador lanzará una excepción. Es buena práctica implementar manejo de errores para estas situaciones:

try {
  // Intentamos almacenar un objeto grande
  const largeObject = { /* datos extensos */ };
  localStorage.setItem('largeData', JSON.stringify(largeObject));
} catch (e) {
  if (e instanceof DOMException && e.name === 'QuotaExceededError') {
    console.error('Error: Límite de almacenamiento excedido');
    // Implementar estrategia de gestión (eliminar datos antiguos, etc.)
  }
}

Políticas de mismo origen (Same-Origin Policy)

Una de las restricciones de seguridad más importantes es la política de mismo origen. Tanto localStorage como sessionStorage están vinculados al origen del documento, que se define por la combinación de:

  • Protocolo (http, https)
  • Dominio (ejemplo.com)
  • Puerto (80, 443, etc.)
// Este almacenamiento solo es accesible desde https://miapp.com:443
// No puede ser accedido desde http://miapp.com o https://otrositio.com
localStorage.setItem('userData', JSON.stringify({name: 'Ana'}));

Esta restricción tiene importantes implicaciones:

  • Los datos almacenados en https://app.ejemplo.com no son accesibles desde https://ejemplo.com
  • Subdominios diferentes se consideran orígenes distintos
  • El cambio de protocolo (HTTP a HTTPS) crea un origen diferente

Vulnerabilidades de seguridad

El almacenamiento web no está cifrado por defecto, lo que presenta varios riesgos de seguridad:

  • Ataques XSS: Un script malicioso inyectado en la página puede acceder a todo el almacenamiento del dominio:
// Código potencialmente vulnerable
function displayUserComment(comment) {
  // Peligroso: insertar contenido sin sanitizar
  document.getElementById('comments').innerHTML += comment;
  
  // Mejor enfoque: sanitizar el contenido
  const sanitizedComment = DOMPurify.sanitize(comment);
  document.getElementById('comments').innerHTML += sanitizedComment;
}
  • Persistencia de datos sensibles: Nunca debemos almacenar información confidencial sin cifrado:
// Enfoque incorrecto
localStorage.setItem('authToken', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');

// Mejor enfoque (aunque no perfecto)
function encryptData(data, key) {
  // Implementación de cifrado (usando SubtleCrypto API)
  return encryptedData;
}

const encryptedToken = encryptData(token, secretKey);
localStorage.setItem('secureToken', encryptedToken);

Modo incógnito y limpieza de navegador

Es importante considerar que los datos en localStorage persisten incluso después de cerrar el navegador, pero pueden ser eliminados en estas situaciones:

  • Cuando el usuario borra los datos de navegación
  • En modo incógnito/privado, donde los datos se eliminan al cerrar la ventana
  • Si el usuario tiene configurado su navegador para rechazar cookies o almacenamiento local
// Detectar si localStorage está disponible
function isStorageAvailable() {
  try {
    const testKey = '__storage_test__';
    localStorage.setItem(testKey, testKey);
    localStorage.removeItem(testKey);
    return true;
  } catch (e) {
    return false;
  }
}

// Implementar alternativa si no está disponible
if (!isStorageAvailable()) {
  console.warn('localStorage no disponible, usando alternativa en memoria');
  // Implementar solución alternativa
}

Consideraciones de rendimiento

Aunque el acceso al almacenamiento web es síncrono, operaciones frecuentes o con grandes volúmenes de datos pueden afectar el rendimiento:

  • Las operaciones de lectura/escritura bloquean el hilo principal
  • La serialización/deserialización de objetos complejos puede ser costosa
// Enfoque ineficiente: múltiples operaciones individuales
for (let i = 0; i < 1000; i++) {
  localStorage.setItem(`item_${i}`, `value_${i}`);
}

// Enfoque más eficiente: operación única con estructura de datos
const batchData = {};
for (let i = 0; i < 1000; i++) {
  batchData[`item_${i}`] = `value_${i}`;
}
localStorage.setItem('batchItems', JSON.stringify(batchData));

Comprender estas limitaciones y consideraciones de seguridad es esencial para implementar soluciones de almacenamiento web robustas y seguras en aplicaciones JavaScript modernas.

Patrones de diseño: Implementación de wrapper classes y sistemas de caché

El uso directo de localStorage y sessionStorage puede volverse complejo cuando las aplicaciones crecen. Para mejorar la mantenibilidad, testabilidad y agregar funcionalidades avanzadas, es recomendable implementar patrones de diseño que encapsulen estas APIs nativas. Veremos cómo crear abstracciones elegantes que simplifiquen el trabajo con el almacenamiento web.

Wrapper classes para almacenamiento

Una clase wrapper proporciona una interfaz más amigable sobre las APIs nativas, añadiendo funcionalidades como manejo automático de JSON, expiración de datos y validación:

class StorageManager {
  constructor(storageType = 'local') {
    this.storage = storageType === 'local' ? localStorage : sessionStorage;
  }

  set(key, value, expiresIn = null) {
    const item = {
      value,
      timestamp: Date.now()
    };
    
    if (expiresIn) {
      item.expiresAt = Date.now() + expiresIn;
    }
    
    this.storage.setItem(key, JSON.stringify(item));
    return true;
  }

  get(key) {
    const item = this.storage.getItem(key);
    if (!item) return null;
    
    const parsedItem = JSON.parse(item);
    
    // Verificar expiración
    if (parsedItem.expiresAt && parsedItem.expiresAt < Date.now()) {
      this.remove(key);
      return null;
    }
    
    return parsedItem.value;
  }

  remove(key) {
    this.storage.removeItem(key);
  }

  clear() {
    this.storage.clear();
  }
}

Esta implementación ofrece varias ventajas:

  • Manejo automático de serialización/deserialización JSON
  • Expiración de datos configurable
  • Interfaz unificada para localStorage y sessionStorage
  • Encapsulación que facilita cambios futuros

Podemos utilizar esta clase de manera sencilla:

const userStorage = new StorageManager('local');

// Guardar datos con expiración de 1 hora
userStorage.set('userProfile', { name: 'Carlos', role: 'admin' }, 3600000);

// Recuperar datos
const profile = userStorage.get('userProfile');
console.log(profile); // { name: 'Carlos', role: 'admin' }

Sistemas de caché con almacenamiento web

Implementar un sistema de caché es otro patrón común que mejora el rendimiento de aplicaciones que realizan operaciones costosas o peticiones a APIs:

class CacheService {
  constructor(defaultExpiration = 3600000) { // 1 hora por defecto
    this.storage = new StorageManager('local');
    this.defaultExpiration = defaultExpiration;
  }

  async fetch(url, options = {}) {
    const cacheKey = `cache_${this.hashUrl(url)}`;
    const cachedResponse = this.storage.get(cacheKey);
    
    if (cachedResponse) {
      return cachedResponse;
    }
    
    try {
      const response = await fetch(url, options);
      const data = await response.json();
      
      // Guardar en caché
      this.storage.set(
        cacheKey, 
        data, 
        options.expiration || this.defaultExpiration
      );
      
      return data;
    } catch (error) {
      console.error('Error fetching data:', error);
      throw error;
    }
  }
  
  invalidate(url) {
    const cacheKey = `cache_${this.hashUrl(url)}`;
    this.storage.remove(cacheKey);
  }
  
  hashUrl(url) {
    // Implementación simple de hash para URLs
    let hash = 0;
    for (let i = 0; i < url.length; i++) {
      hash = ((hash << 5) - hash) + url.charCodeAt(i);
      hash |= 0; // Convertir a entero de 32 bits
    }
    return hash.toString(16);
  }
}

Este sistema de caché ofrece beneficios significativos:

  • Reducción de peticiones a servidores externos
  • Mejora de rendimiento para operaciones repetitivas
  • Funcionamiento offline para datos previamente cargados
  • Control granular sobre la invalidación de caché

Ejemplo de uso del sistema de caché:

const apiCache = new CacheService();

// Componente que muestra productos
async function loadProducts() {
  try {
    // Los datos se cargarán desde la caché si están disponibles
    const products = await apiCache.fetch('https://api.example.com/products');
    renderProductList(products);
  } catch (error) {
    showErrorMessage('No se pudieron cargar los productos');
  }
}

// Invalidar caché cuando se actualiza un producto
function updateProduct(productId, data) {
  // Actualizar en el servidor
  fetch(`https://api.example.com/products/${productId}`, {
    method: 'PUT',
    body: JSON.stringify(data)
  }).then(() => {
    // Invalidar la caché para forzar una recarga fresca
    apiCache.invalidate('https://api.example.com/products');
  });
}

Patrón Repository con almacenamiento local

El patrón Repository proporciona una abstracción sobre el mecanismo de almacenamiento, permitiendo operaciones CRUD consistentes independientemente del origen de datos:

class UserRepository {
  constructor() {
    this.storage = new StorageManager('local');
    this.collectionKey = 'users';
  }
  
  getAll() {
    return this.storage.get(this.collectionKey) || [];
  }
  
  getById(id) {
    const users = this.getAll();
    return users.find(user => user.id === id) || null;
  }
  
  save(user) {
    const users = this.getAll();
    const existingIndex = users.findIndex(u => u.id === user.id);
    
    if (existingIndex >= 0) {
      // Actualizar usuario existente
      users[existingIndex] = { ...users[existingIndex], ...user };
    } else {
      // Crear nuevo usuario con ID generado
      users.push({ 
        ...user, 
        id: this.generateId(),
        createdAt: Date.now()
      });
    }
    
    this.storage.set(this.collectionKey, users);
    return user;
  }
  
  delete(id) {
    const users = this.getAll();
    const filteredUsers = users.filter(user => user.id !== id);
    
    if (filteredUsers.length !== users.length) {
      this.storage.set(this.collectionKey, filteredUsers);
      return true;
    }
    
    return false;
  }
  
  generateId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2);
  }
}

Este patrón facilita la gestión de colecciones de datos y proporciona una interfaz uniforme para operaciones CRUD:

const userRepo = new UserRepository();

// Crear usuario
const newUser = userRepo.save({ name: 'Elena', email: 'elena@example.com' });

// Obtener todos los usuarios
const allUsers = userRepo.getAll();

// Actualizar usuario
userRepo.save({ id: newUser.id, role: 'editor' });

// Eliminar usuario
userRepo.delete(newUser.id);

La implementación de estos patrones de diseño mejora significativamente la arquitectura de aplicaciones que utilizan almacenamiento web, facilitando el mantenimiento, las pruebas y la escalabilidad del código. Además, proporcionan una capa de abstracción que permite cambiar la implementación subyacente sin afectar al código cliente.

Sincronización entre pestañas: Detección de cambios con el evento storage

Cuando trabajamos con aplicaciones web modernas, es común que los usuarios tengan múltiples pestañas abiertas de la misma aplicación. En estos escenarios, mantener la sincronización de datos entre estas pestañas se vuelve crucial para garantizar una experiencia coherente. JavaScript proporciona un mecanismo elegante para detectar cambios en el almacenamiento web a través del evento storage.

El evento storage se dispara automáticamente en todas las pestañas o ventanas (excepto en la que realizó el cambio) cuando se modifica el localStorage. Esta característica permite implementar sistemas de comunicación entre diferentes instancias de nuestra aplicación sin necesidad de servidores intermedios.

// Escuchar cambios en localStorage desde otras pestañas
window.addEventListener('storage', (event) => {
  console.log('Cambio detectado en otra pestaña:');
  console.log('Clave modificada:', event.key);
  console.log('Valor anterior:', event.oldValue);
  console.log('Nuevo valor:', event.oldValue);
  console.log('URL de la pestaña que realizó el cambio:', event.url);
});

El objeto event proporciona información detallada sobre el cambio realizado:

  • key: La clave que se modificó
  • oldValue: El valor anterior (antes del cambio)
  • newValue: El nuevo valor establecido
  • url: La URL de la página donde se realizó el cambio
  • storageArea: Referencia al objeto de almacenamiento (localStorage o sessionStorage)

Implementación de un sistema de notificaciones entre pestañas

Podemos aprovechar este mecanismo para crear un sistema de notificaciones que mantenga sincronizadas todas las instancias de nuestra aplicación:

class TabSynchronizer {
  constructor(channelName = 'app-sync') {
    this.channel = channelName;
    this.listeners = new Map();
    
    // Escuchar eventos de otras pestañas
    window.addEventListener('storage', (event) => {
      if (event.key === this.channel) {
        try {
          const data = JSON.parse(event.newValue);
          if (data && data.type && this.listeners.has(data.type)) {
            this.listeners.get(data.type).forEach(callback => {
              callback(data.payload);
            });
          }
        } catch (error) {
          console.error('Error al procesar mensaje entre pestañas:', error);
        }
      }
    });
  }
  
  // Enviar mensaje a otras pestañas
  broadcast(type, payload) {
    const message = JSON.stringify({
      type,
      payload,
      timestamp: Date.now()
    });
    
    localStorage.setItem(this.channel, message);
    // Necesario para que otras pestañas detecten cambios consecutivos
    // con el mismo tipo de mensaje
    setTimeout(() => localStorage.removeItem(this.channel), 100);
  }
  
  // Suscribirse a un tipo de mensaje
  subscribe(type, callback) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(callback);
    
    // Devolver función para cancelar suscripción
    return () => {
      const callbacks = this.listeners.get(type);
      callbacks.delete(callback);
      if (callbacks.size === 0) {
        this.listeners.delete(type);
      }
    };
  }
}

Este sistema permite implementar comunicación bidireccional entre pestañas de forma sencilla:

// Crear instancia del sincronizador
const tabSync = new TabSynchronizer();

// Suscribirse a actualizaciones de usuario
const unsubscribe = tabSync.subscribe('user-updated', (userData) => {
  console.log('Datos de usuario actualizados en otra pestaña:', userData);
  updateUserInterface(userData);
});

// Cuando el usuario actualiza su perfil
function updateUserProfile(newData) {
  // Actualizar en el servidor
  api.updateProfile(newData)
    .then(response => {
      // Guardar localmente
      localStorage.setItem('user', JSON.stringify(response.data));
      
      // Notificar a otras pestañas
      tabSync.broadcast('user-updated', response.data);
      
      showSuccessMessage('Perfil actualizado correctamente');
    });
}

Sincronización de estado de autenticación

Un caso de uso común es mantener sincronizado el estado de autenticación entre pestañas. Cuando un usuario cierra sesión en una pestaña, probablemente desee que todas las demás pestañas también reflejen este cambio:

class AuthSynchronizer {
  constructor() {
    this.tabSync = new TabSynchronizer('auth-channel');
    
    // Escuchar eventos de autenticación
    this.tabSync.subscribe('auth-changed', (authState) => {
      if (authState.status === 'logged-out') {
        // Limpiar datos locales
        localStorage.removeItem('authToken');
        localStorage.removeItem('userData');
        
        // Redirigir a página de login
        window.location.href = '/login?reason=session_ended';
      }
    });
  }
  
  // Llamar cuando el usuario cierra sesión manualmente
  logout() {
    // Limpiar datos de autenticación
    localStorage.removeItem('authToken');
    localStorage.removeItem('userData');
    
    // Notificar a otras pestañas
    this.tabSync.broadcast('auth-changed', {
      status: 'logged-out',
      timestamp: Date.now()
    });
    
    // Redirigir a login
    window.location.href = '/login';
  }
}

// Uso
const authSync = new AuthSynchronizer();
document.getElementById('logout-button').addEventListener('click', () => {
  authSync.logout();
});

Sincronización de carrito de compras

Otro escenario común es la sincronización de un carrito de compras entre diferentes pestañas de una tienda online:

class CartSynchronizer {
  constructor() {
    this.tabSync = new TabSynchronizer('cart-channel');
    
    // Inicializar carrito desde localStorage
    this.cart = JSON.parse(localStorage.getItem('cart')) || { items: [], total: 0 };
    
    // Escuchar cambios en el carrito desde otras pestañas
    this.tabSync.subscribe('cart-updated', (newCart) => {
      this.cart = newCart;
      this.updateCartUI();
    });
  }
  
  // Añadir producto al carrito
  addToCart(product, quantity = 1) {
    // Buscar si el producto ya existe en el carrito
    const existingItem = this.cart.items.find(item => item.id === product.id);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.cart.items.push({
        id: product.id,
        name: product.name,
        price: product.price,
        quantity
      });
    }
    
    // Recalcular total
    this.cart.total = this.cart.items.reduce(
      (sum, item) => sum + (item.price * item.quantity), 0
    );
    
    // Guardar en localStorage
    localStorage.setItem('cart', JSON.stringify(this.cart));
    
    // Notificar a otras pestañas
    this.tabSync.broadcast('cart-updated', this.cart);
    
    // Actualizar interfaz
    this.updateCartUI();
  }
  
  updateCartUI() {
    // Actualizar contador de items
    document.querySelector('.cart-count').textContent = 
      this.cart.items.reduce((sum, item) => sum + item.quantity, 0);
    
    // Actualizar total
    document.querySelector('.cart-total').textContent = 
      `$${this.cart.total.toFixed(2)}`;
      
    // Actualizar lista de productos si está visible
    const cartList = document.querySelector('.cart-items');
    if (cartList) {
      // Implementación de actualización del DOM
    }
  }
}

Consideraciones y limitaciones

Al implementar sistemas de sincronización entre pestañas, es importante tener en cuenta algunas limitaciones:

  • El evento storage no se dispara en la misma pestaña que realizó el cambio
  • Existe un límite de tamaño para los datos que podemos almacenar
  • La comunicación es asíncrona pero no garantiza entrega inmediata
  • No funciona entre dominios diferentes debido a la política de mismo origen

Para casos más complejos donde estas limitaciones sean problemáticas, podemos considerar alternativas como WebSockets o la API BroadcastChannel:

// Alternativa usando BroadcastChannel (no soportada en todos los navegadores)
class ModernTabSynchronizer {
  constructor(channelName) {
    this.channel = new BroadcastChannel(channelName);
    this.listeners = new Map();
    
    this.channel.addEventListener('message', (event) => {
      const { type, payload } = event.data;
      if (this.listeners.has(type)) {
        this.listeners.get(type).forEach(callback => callback(payload));
      }
    });
  }
  
  broadcast(type, payload) {
    this.channel.postMessage({ type, payload, timestamp: Date.now() });
  }
  
  subscribe(type, callback) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(callback);
    
    return () => {
      const callbacks = this.listeners.get(type);
      callbacks.delete(callback);
      if (callbacks.size === 0) {
        this.listeners.delete(type);
      }
    };
  }
}

La implementación de sistemas de sincronización entre pestañas mejora significativamente la experiencia de usuario en aplicaciones web modernas, garantizando coherencia de datos y reduciendo la confusión que puede surgir cuando diferentes instancias de la misma aplicación muestran estados diferentes.

Aprende JavaScript online

Otras lecciones de JavaScript

Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a JavaScript y certifícate

Ejercicios de programación de JavaScript

Evalúa tus conocimientos de esta lección LocalStorage y SessionStorage con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.