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 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.
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.
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 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:
-
Primeira pintura. O
localStorageé síncrono, então a primeira renderização pode ler o estado diretamente. Um app só com IndexedDB tem pelo menos uma ida e volta assíncrona antes de saber o que desenhar — o que é um flash de editor vazio ou um indicador de carregamento, em todo carregamento, para resolver um problema que a maioria das pessoas nunca tem. - Navegação privada. O IndexedDB está disponível mas é efêmero em alguns modos privados, e bloqueado por completo em outros. Nenhum dos dois backends é confiável ali, então ter dois vale mais do que escolher o melhor.
-
Superfície de falha. O armazenamento IndexedDB aqui é um único registro — o
estado inteiro do app, com a chave inteira literal
1, em um object store sem key path e sem índices. Isso é olocalStoragecom um teto maior, deliberadamente. Modelar as abas como linhas compraria escritas parciais e custaria gestão de transações, complexidade de migração e uma classe de bugs de consistência que o app hoje não tem.
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.
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:
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.
- Pedir persistência. Como acima — a mudança de maior valor disponível, e é uma linha só.
- Mostrar qual backend está em uso. O app sabe que promoveu para o IndexedDB e nunca conta. Alguém cujas notas ultrapassaram o Web Storage é alguém cujas notas são grandes o bastante para valer a pena exportar uma cópia.
- Guiar a barra de status pela escrita, não pelo agendamento. Hoje o status é definido de forma otimista em volta do salvamento. Uma falha que nenhum dos dois backends conseguisse contornar ainda deixaria Salvo na tela, que é exatamente a classe de mentira de que este artigo inteiro trata.
- Capturar a rejeição do IndexedDB. A segunda falha seguida é a que vale interromper a pessoa para avisar.
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.
-
O
localStorageé 5 MiB por origem, e a contabilidade em UTF-16 corta isso pela metade em caracteres. -
navigator.storage.estimate()não informa o uso do Web Storage. Não condicione uma decisão sobre Web Storage a ela. - Promova na escrita que falhou, não em uma previsão — a escrita é o único oráculo confiável, e ela também pega navegação privada e armazenamento bloqueado.
- Apague a cópia antiga no momento da promoção, ou uma escrita bem-sucedida e velha vence a nova no carregamento seguinte.
- Mantenha a flag de backend em memória. A demoção que você ganha de graça é o comportamento que você quer.
-
Coloque um
versionno estado persistido desde o primeiro dia. -
Chame
navigator.storage.persist(). Cota dá para capturar; despejo não.