Structured Persistence with TinyBase

4 min read

The Requirement That Grew

The initial requirement was straightforward: preserve user progress. Whatever they wrote should still be there when they returned or refreshed the page. We stored drafts in localStorage, and it worked quietly, doing its job.

But the product evolved. What was once a single block of text became structured data—multiple sections, nested subsections, all tied to a single project. Then users needed multiple projects, each with its own draft and history. We also needed to track which edits came from AI suggestions and which from the user.

The shape of the data changed. And with it, the limits of the system became visible.


Where localStorage Broke Down

The original approach fractured under pressure:

  • Rigid structure — Every update required parsing and serializing the entire object. The structure couldn’t evolve with the product.
  • Storage limits — localStorage offers ~5–10MB per domain. Drafts were no longer small.
  • Blocking writes — Synchronous writes blocked the main thread, causing UI responsiveness to suffer under frequent updates.

What once felt simple started to feel fragile. We needed a system that could handle scale and structure without compromising performance—and ideally support undo/redo and persist drafts even after session expiry.


The Solution

I introduced TinyBase backed by IndexedDB, moving toward a client-side persistence architecture.

This shift wasn’t just about changing storage—it was about changing how data lived in the system:

  • A reactive data store with fine-grained updates
  • Tabular data modeling that naturally supports structured drafts
  • Built-in persistence using IndexedDB
  • Asynchronous, non-blocking writes
  • Much larger storage limits (typically hundreds of MB, managed dynamically by the browser based on available disk space)
  • Checkpoints enabling undo/redo functionality
  • Cross-tab synchronization
  • Offline support by default

More importantly, it allowed the data model to evolve alongside the product.


What Actually Changed

With localStorage, a draft looked like this:

{
  "content": "{ \"sections\": [...] }",  // stringified blob
  "lastModified": 1234567890
}

Every save meant serializing the entire structure. Every load meant parsing it back. No granularity, no history.

With TinyBase, the same draft becomes relational:

// TinyBase stores data as tables → rows → cells
{
  projects: {
    p1: { name: "Q1 Report" }
  },
  sections: {
    s1: { projectId: "p1", title: "Summary", order: 0 }
  },
  subsections: {
    sub1: { sectionId: "s1", content: "...", editedBy: "user" }
  }
}
// Checkpoints managed separately via TinyBase's Checkpoints API

Now updates are incremental—change one subsection without touching the rest. History comes built-in. And the structure can evolve without migrations.

Architecture

The system is designed as a set of isolated layers with strictly controlled data flow to avoid reactive feedback loops and ensure consistency under frequent updates.

  1. UI Layer (React) The UI renders the draft and captures user input. It subscribes to in-memory state but does not directly interact with persistence.

  2. State Layer (TinyBase Store + React Bindings) Acts as the single source of truth using a TinyBase store. The data is modeled as structured records (projects → sections → subsections), enabling fine-grained updates.

    React components subscribe through TinyBase hooks (e.g., useCell, useRow, or useTable), which provide selective subscriptions to avoid unnecessary re-renders. The store itself is framework-agnostic; the hooks are only a binding layer.

  3. History (TinyBase Checkpoints) History is not a separate architectural layer but a capability provided by TinyBase through checkpoints. It allows capturing state transitions and enabling undo/redo without introducing a parallel system.

    Conceptually, it behaves like a layer, but operationally it is part of the store itself.

  4. Persistence Layer (IndexedDB) Handles asynchronous, non-blocking storage. Writes are debounced and batched to avoid performance issues and race conditions.

Data Flow

  • User input updates in-memory state
  • State changes trigger history updates
  • Persistence layer listens and schedules writes
  • On load, state is reconstructed from storage

Persistence is isolated from the render cycle. It never pushes state back into the UI synchronously, preventing overwrite loops caused by re-renders.

When Not to Use This Approach

This architecture is powerful, but it introduces cognitive and implementation overhead. It’s most valuable when drafts are large, structured, and user progress is critical. I don’t recommend it when:

  • The data model is simple and unlikely to evolve beyond a flat structure
  • Drafts are small and do not require history or time-travel capabilities
  • Server-side persistence is required immediately (e.g., collaborative or real-time systems)
  • The added complexity of client-side persistence architecture outweighs the product needs

Final Thoughts

The real lesson wasn’t about choosing the right storage layer—it was about recognizing when your data model has outgrown its infrastructure. localStorage is fine for simple key-value persistence. But when your data becomes structured, relational, and needs history, it’s time to reach for better tools.

State is not just what you store. It’s how it’s shaped.


While implementing this architecture, we encountered a subtle React race condition that caused data loss during rapid typing. Read about that bug and its fix →

Read more
#becoming #wandering #working #discovering