Notepad Neo

O que acontece quando o localStorage acaba

Um salvamento automático que para de funcionar em silêncio é pior do que um que nunca funcionou, porque a pessoa não tem motivo para desconfiar. A parte interessante não é o fallback — é decidir quando acioná-lo.

DH

— cria e mantém o Notepad Neo

· atualizado em · 11 min de leitura

O Notepad Neo mantém cada nota no dispositivo. Não há conta, não há sincronização e não há servidor, o que faz do armazenamento o único lugar onde a nota existe — e transforma uma escrita que falha em perda de dados, não em um inconveniente.

O localStorage é a primeira escolha natural. Ele é síncrono, tem suporte universal, sobrevive a um recarregamento e guarda strings, que é o que um estado de aplicação serializado é. Ele também tem um teto rígido por origem que uma nota longa alcança mais rápido do que se imagina, e o modo como ele falha é o pior possível.

O modo de falha

localStorage.setItem() lança QuotaExceededError quando a escrita não cabe. Se ninguém capturar, a exceção sobe a partir de um callback de salvamento automático com debounce que ninguém está aguardando, cai no console e desaparece.

Do lado de quem usa não há sinal nenhum. O editor continua aceitando as teclas. A barra de status continua dizendo Salvo, porque a barra de status foi atualizada pelo código que agendou o salvamento, e não pelo salvamento em si. A nota continua intacta no DOM enquanto a aba estiver aberta. Aí a aba fecha, e tudo desde a última escrita bem-sucedida se perde.

O teto é menor do que o número que você já leu

O valor citado com frequência é 5 MB por origem e, segundo a MDN, são 5 MiB para o localStorage mais outros 5 MiB para o sessionStorage. Só que o Web Storage guarda strings UTF-16, e os navegadores contabilizam a cota em bytes — então uma nota em ASCII consome dois bytes por caractere, e o teto prático fica em torno de 2,5 milhões de caracteres, não cinco milhões.

Isso ainda é muito texto puro. Não é muito texto formatado: uma nota com spans de formatação, estilos inline e uma imagem em base64 chega lá bem antes, e o estado inteiro do app — todas as abas — divide o mesmo orçamento.

A cota do Web Storage comparada ao pool do IndexedDB O localStorage é limitado a 5 MiB por origem em todos os navegadores. O IndexedDB puxa de um pool muito maior: o Chrome permite a uma origem até 60 por cento do disco total, o Firefox permite o menor entre 10 por cento do disco ou 10 GiB no modo best-effort, e o Safari permite cerca de 60 por cento do disco para aplicativos de navegador. A diferença é de ordens de grandeza, não de um fator de dois. PER-ORIGIN LIMITS · MDN, 2026 localStorage 5 MiB — every browser, fixed IndexedDB · Chrome 60% of total disk IndexedDB · Firefox min(10% of disk, 10 GiB) — best effort IndexedDB · Safari ~60% of disk in a browser app, ~15% when embedded The bars are not to scale — they cannot be. On a 512 GB disk the Chrome figure is around 300 GB, which is roughly sixty thousand times the localStorage cap.
Cota é um limite superior, não uma reserva. Nada garante que uma origem consiga de fato guardar tudo aquilo, e é por isso que tratar o erro continua obrigatório mesmo do lado do pool grande.

Promover na falha, não na previsão

O desenho óbvio é medir antes de escrever. Perguntar quanto espaço sobrou e, se o estado estiver perto do teto, migrar para o IndexedDB preventivamente. Existe até uma API que parece responder exatamente essa pergunta.

Ela não responde. navigator.storage.estimate() informa o pool do IndexedDB e do Cache. O uso do Web Storage não está incluído nele. Perguntar a ela o quão cheio está o localStorage devolve um número sobre outra coisa completamente diferente.

Prever a cota versus reagir a uma escrita que falhou O caminho preditivo pergunta ao navigator.storage.estimate quanto espaço resta, mas esse número exclui o Web Storage por completo e é acolchoado por causa de dados de outras origens, então a decisão é tomada sobre o número errado. O caminho reativo tenta a escrita, captura a falha e promove para o IndexedDB — usando o único sinal que é sempre exato. PREDICT await navigator.storage.estimate() remaining < threshold ? switch to IndexedDB ✕ excludes Web Storage entirely ✕ padded for cross-origin data, by design REACT localStorage.setItem(...) did it throw ? promote, then write to IndexedDB ✓ the write itself is the only reliable oracle ✓ one wasted attempt, once per session
A versão reativa custa uma escrita que falha. Essa escrita ia falhar de qualquer jeito — a versão preditiva apenas a faz falhar em silêncio e mais tarde.

Há um motivo mais profundo para preferir reagir. A cota não é a única coisa que faz uma escrita no localStorage falhar. O Safari em navegação privada historicamente lançava exceção em qualquer escrita. Uma política corporativa pode desabilitar dados de site. Uma pessoa pode bloquear o armazenamento para a origem. Nada disso é visível para uma estimativa, e tudo isso significa a mesma coisa: este backend de armazenamento não é utilizável, use o outro.

Capturar o erro de cota é mais desajeitado do que deveria

Não existe forma portátil de identificar uma falha de cota. A resposta moderna é uma DOMException com name === 'QuotaExceededError'. O Firefox historicamente lançava NS_ERROR_DOM_QUOTA_REACHED. O Safari antigo lançava QUOTA_EXCEEDED_ERR. E os casos de navegação privada e de política lançam coisas que não são erros de cota, mas que operacionalmente significam exatamente o mesmo.

Então o código não ramifica pelo tipo do erro. Ele trata toda falha do mesmo jeito, e o try é a lógica inteira:

function saveToLS(state: AppState): boolean {
  try {
    localStorage.setItem(LS_KEY, JSON.stringify(state));
    return true;
  } catch {
    // Any failure means this backend is unusable — quota, private mode,
    // blocked site data. The distinction does not change what we do next.
    return false;
  }
}

Um booleano em vez de um erro relançado, porque quem chama tem um caminho de recuperação e não precisa do diagnóstico. Distinguir os casos permitiria escrever uma mensagem melhor no console e não mudaria nada no comportamento.

A promoção, e a única linha que a torna correta

O adaptador carrega uma única flag. Enquanto ela é falsa, os salvamentos vão para o localStorage. A primeira escrita que falha inverte a flag, e tudo depois disso vai para o IndexedDB:

async save(tabs: TabState[], activeTabId: string): Promise<void> {
  const state: AppState = { version: CURRENT_VERSION, tabs, activeTabId };

  if (this.useIDB) {
    await saveToIDB(state);
    return;
  }

  const ok = saveToLS(state);
  if (!ok) {
    this.useIDB = true;
    // Drop the localStorage copy at the moment of promotion. load() checks
    // localStorage first, so leaving the last-successful (older) state behind
    // would make it win over the fresh IndexedDB one on the next visit.
    try { localStorage.removeItem(LS_KEY); } catch {}
    await saveToIDB(state);
  }
}

O removeItem é a linha que importa, e é a mais fácil de esquecer. O load() lê o localStorage primeiro e só cai para o IndexedDB se não houver nada lá. Deixe a cópia velha para trás e a próxima visita restaura o último estado que coube — descartando em silêncio tudo o que foi escrito depois de a cota estourar. Esse é um bug pior do que aquele que o fallback existe para corrigir, porque parece um carregamento bem-sucedido.

O ciclo de promoção, incluindo a demoção que ninguém projetou Os salvamentos vão para o localStorage até um falhar. Na falha, o adaptador liga uma flag em memória, remove a cópia velha do localStorage e escreve no IndexedDB pelo resto da sessão. Como a flag não é persistida, um recarregamento começa de novo em modo localStorage; o carregamento cai para o IndexedDB e funciona, e o salvamento seguinte tenta o localStorage outra vez. Se a nota tiver encolhido nesse meio-tempo, a tentativa dá certo e o app volta silenciosamente ao backend anterior. save → localStorage useIDB = false throws promote useIDB = true removeItem(LS_KEY) save → IndexedDB for the rest of the session tab closes · page reloads load() LS empty → falls to IDB useIDB starts false again next save tries localStorage first — again if the note has shrunk, it fits, and the app quietly demotes Nobody designed this. It falls out of the flag being per-session, and it is the behaviour you want.
A demoção que se autocorrige é um acidente genuíno do desenho. Como a flag vive em memória e não no armazenamento, todo recarregamento testa de novo o backend mais barato e volta a ele quando dá.

O que de fato é escrito

O estado inteiro do app é um único objeto JSON, e cada aba dentro dele tem a mesma forma:

export interface TabState {
  id: string;
  title: string;
  content: string;      // HTML for rich tabs, raw text for plain and markdown
  format: TabFormat;    // 'rich' | 'plain' | 'markdown'
  color: string | null;
  zoom: number;
  createdAt: number;
  updatedAt: number;
}

content é o campo interessante, e é a razão de o teto chegar antes do que uma contagem de caracteres sugere. Numa aba formatada ele é HTML serializado, então um parágrafo que a pessoa vê como quarenta caracteres é guardado como quarenta caracteres mais toda a marcação que carrega a formatação — um <span style="font-size: 18px; line-height: 1.4"> são cinquenta bytes de sobrecarga antes de uma única letra de conteúdo.

Esse é o imposto por usar o DOM como modelo do documento em vez de manter uma estrutura separada. É a mesma troca discutida no artigo sobre tamanho de fonte, vista do lado do armazenamento: os exportadores conseguem ler a formatação direto do DOM vivo, e a persistência paga por isso em bytes.

O caminho de leitura espelha exatamente o de escrita, e a ordem nele é estrutural:

async load(): Promise<AppState> {
  let state = loadFromLS();               // synchronous — no round trip on the common path
  if (!state) state = await loadFromIDB(); // only if Web Storage had nothing
  if (!state) return defaultState();
  return migrate(state);
}

localStorage primeiro, porque na esmagadora maioria dos carregamentos ele tem o estado e o app pode renderizar sem esperar. IndexedDB depois, porque ele só guarda alguma coisa quando uma promoção já aconteceu. E um estado padrão por último, que é ao mesmo tempo o caminho da primeira visita e o caminho de "armazenamento indisponível neste contexto" — deliberadamente indistinguíveis, porque não há nada de útil a dizer sobre a diferença naquele ponto.

// One record: the whole app state, at the literal integer key 1.
const req = tx.objectStore(IDB_STORE).get(1);

Por que não usar IndexedDB para tudo?

Ele é o armazenamento maior e mais capaz. Também é assíncrono, transacional, versionado e baseado em eventos em vez de promessas, o que significa que todo ponto de chamada ganha um await ou um wrapper.

Os custos que decidiram a questão:

Ou seja: localStorage para o caso comum, IndexedDB quando ele para de funcionar. Dois backends, uma forma de dado, e o mesmo estado serializado nos dois caminhos.

Versione o estado antes de precisar

Estado persistido sobrevive ao código que o escreveu. Alguém vai abrir este app depois de tê-lo usado pela última vez dezoito meses atrás, e o estado no navegador dessa pessoa vai ter a forma de uma versão antiga.

export interface AppState {
  version: number;
  tabs: TabState[];
  activeTabId: string;
}

export const CURRENT_VERSION = 1;

A função de migração hoje é um esqueleto — ela carimba a versão atual e retorna. E esse é o ponto. O campo custa um inteiro por salvamento e é a diferença entre acrescentar uma migração depois e ter que adivinhar, pela forma de um objeto, qual versão o escreveu.

Duas arestas, declaradas em vez de escondidas

O loadFromIDB envolve tudo em um try que retorna null, então um IndexedDB bloqueado ou indisponível degrada para um estado padrão novo em vez de lançar exceção. Essa é a decisão certa no carregamento — mas também significa que uma falha transitória fica indistinguível de uma primeira visita.

O saveToIDB é aguardado com await, mas não é envolvido em try no ponto de chamada, então uma escrita rejeitada aparece como unhandled rejection. No caminho da promoção essa é a segunda falha seguida e genuinamente não sobrou nada para onde recorrer — mas ainda assim ela deveria ser capturada e mostrada à pessoa, não ao console.

Aplique debounce ao salvamento, e escolha os intervalos de propósito

Serializar cada aba e escrever o estado inteiro a cada tecla é desperdício, e com um backend síncrono é desperdício na thread principal. Mas um debounce longo alarga a janela em que uma queda custa trabalho.

Há dois debounces na cadeia, em níveis diferentes, fazendo trabalhos diferentes:

A cadeia de debounce do salvamento automático Uma tecla atualiza o DOM imediatamente. O editor agrupa eventos de input por 100 milissegundos antes de emitir uma mudança. A mudança marca a aba como não salva e inicia um segundo debounce de 500 milissegundos antes da escrita de fato, que muda o status para salvando e depois para salvo. Um handler de beforeunload descarrega a escrita pendente quando a aba fecha. keystroke DOM updates 100 ms editor debounce change status: unsaved 500 ms save debounce write saved beforeunload — flush immediately, skipping both debounces Worst case exposure is 600 ms of typing. The beforeunload flush is fire-and-forget by necessity — the handler cannot await a promise, so an IndexedDB write started there may not complete. That is the strongest argument for keeping the debounce short rather than relying on the flush.
O debounce de 100 ms do editor agrupa eventos brutos de input. O de 500 ms do salvamento agrupa mudanças lógicas. Fundir os dois em um número só significaria serializar demais ou deixar a barra de status mentindo sobre o que já foi escrito.

Repare no que deliberadamente não tem debounce: a sincronização de direção por bloco roda de forma síncrona a cada evento de input, fora dos dois temporizadores, para que o HTML salvo sempre carregue seus atributos dir. Isso é tratado à parte.

Despejo é o risco que ninguém planeja

Cota é a falha que você consegue capturar. Despejo é a que você não consegue.

Por padrão, todos esses dados são best-effort: o navegador pode apagá-los quando o dispositivo estiver com pouco espaço, e ele apaga os dados de uma origem por inteiro, não em parte. O Chromium usa uma política de menos-recentemente-usado. O Safari é ainda mais agressivo — uma origem sem interação da pessoa por sete dias de uso do navegador tem o armazenamento criado por script apagado, o que, para um bloco de notas offline que alguém abre uma vez a cada quinze dias, é um cenário real e não teórico.

A mitigação é navigator.storage.persist(), que pede que os dados da origem só sejam limpos pela própria pessoa. Ela devolve um booleano, e os navegadores decidem se concedem com base em sinais de engajamento — instalado como PWA, favoritado, visitado com frequência. É um pedido, não uma configuração.

O Notepad Neo hoje não chama essa API, e essa é a lacuna mais clara deste desenho. Custa uma linha e um await na inicialização, degrada para o comportamento atual quando é recusada, e para um app local-first é a diferença entre "suas notas estão neste dispositivo" e "suas notas estão neste dispositivo, a menos que o navegador precise do espaço".

O que faríamos diferente

A lógica de promoção em si se sustentou. As decisões de projeto que valem revisitar são todas sobre o que a pessoa consegue ver.

A lição geral é mais estreita do que "trate seus erros". É que, em um app local-first, a camada de armazenamento é a única que pode dizer a verdade sobre se o trabalho da pessoa existe — então cada ramo dela que falha em silêncio é um ramo que uma hora vai perder o documento de alguém e deixar essa pessoa descobrir no pior momento possível.

← Todos os artigos de engenharia Experimentar o editor