Back to flin
flin

Persistence in the Browser

How we made FlinDB work in the browser -- from SSR with hot module reload, to two-way data binding, form submission through server actions, and the persistence bug that almost broke everything.

Thales & Claude | March 25, 2026 10 min flin
flinflindbbrowserpersistencefrontend

Building a database engine is one thing. Making it work when users interact with it through a web browser is another thing entirely. The browser is a hostile environment for database operations. There is no file system (directly). There is no persistent storage that survives a page refresh (without effort). There is a fundamental boundary between client-side JavaScript and server-side logic that must be bridged carefully.

Sessions 201 and 203 were where FlinDB met the browser. Session 201 fixed the interactivity model -- making buttons click, inputs bind, and forms submit. Session 203 fixed the persistence model -- making sure that data actually survived a page refresh. Together, they revealed the gap between "the database passes all tests" and "the database works when a real person uses it in a real browser."

The Architecture: SSR with Server Actions

FLIN's web rendering model is server-side rendering (SSR) with hot module reload (HMR). This is not a single-page application framework. It is not WASM running in the browser. The FLIN runtime executes on the server, renders HTML, and sends it to the browser.

Browser                          Server
   |                               |
   |  1. Request page              |
   |  --------------------------->  |
   |                               |  2. FLIN runtime renders HTML
   |                               |  3. FlinDB queries execute server-side
   |                               |
   |  4. HTML + JS runtime         |
   |  <---------------------------  |
   |                               |
   |  5. User interacts            |
   |  (clicks, types, submits)     |
   |                               |

The browser receives fully rendered HTML plus a small JavaScript runtime ($flin proxy) that handles client-side state updates. When the user clicks a button or types in an input, the JS runtime updates local state and re-renders affected elements. When the user submits a form that requires server-side operations (like saving to FlinDB), the JS runtime sends a POST request to the server.

Session 201: Making the Browser Interactive

The Problem

Internal tests (2,926 passing) showed a perfectly working system. But when we opened the embedded demos in a browser:

  1. Counter buttons did not increment or decrement
  2. Todo input did not update when typing
  3. Form submit did not create todos
  4. The hero title was cut off at the top of the page

Four bugs, each with a different root cause.

Fix 1: Two-Way Data Binding

The bind={} attribute was supposed to create two-way data binding between an input element and a FLIN variable. But the renderer was not handling it:

flin<input bind={newTodo} />

Was rendering as:

html<input bind="" />

No value. No event listener. The input was decorative. The fix was to generate proper HTML attributes:

rustif attr.name == "bind" {
    if let AttrValue::Expr(expr) = &attr.value {
        let js_expr = expr_to_js(expr);
        let value = eval_expr_to_string_with_scope(expr, vm, scope);
        return format!(
            r#"value="{}" oninput="{} = this.value; _flinUpdate()" data-flin-bind="{}""#,
            escape_html_attr(&value),
            js_expr,
            escape_html_attr(&js_expr)
        );
    }
}

Now <input bind={newTodo}> renders as:

html<input value="" oninput="newTodo = this.value; _flinUpdate()" data-flin-bind="newTodo" />

Three attributes: value for the initial state, oninput for two-way binding, and data-flin-bind for identification. When the user types, the oninput handler updates the $flin proxy variable and calls _flinUpdate() to re-render affected elements.

Fix 2: Form Submission via Server Actions

The form submit handler had a fundamental problem. submit={addTodo} tried to call addTodo() as a JavaScript function. But addTodo only existed as FLIN bytecode on the server -- it was never transpiled to JavaScript.

The solution: server actions. Instead of calling a JS function, the form sends a POST request to a /_action endpoint:

rustif attr.name == "submit" {
    if let Expr::Identifier { name: fn_name, .. } = expr {
        return format!(
            r#"onsubmit="event.preventDefault(); _flinSubmit('{}'); return false;""#,
            escape_html_attr(fn_name)
        );
    }
}

The _flinSubmit() function collects the current state from the $flin proxy and POSTs it to the server:

javascriptfunction _flinSubmit(actionName) {
    const stateData = {};
    for (const key in _state) {
        stateData[key] = $flin[key];
    }

    const formData = new FormData();
    formData.append('_action', actionName);
    formData.append('_state', JSON.stringify(stateData));

    fetch('/_action', {
        method: 'POST',
        body: formData
    })
    .then(response => {
        if (response.ok) {
            window.location.reload();
        }
    });
}

Fix 3: The Server-Side Action Handler

The /_action endpoint receives the action name and current state, compiles the FLIN source with the function call appended, injects the browser state into the VM, and executes:

rustasync fn handle_action_request(
    request: &Request,
    root_path: &Path,
) -> Result<(), Box<dyn Error>> {
    let (action_name, state_json) = parse_form_data(request)?;
    let referer_path = extract_path_from_referer(request);
    let source_file = find_source_file(root_path, &referer_path);

    let source = std::fs::read_to_string(&source_file)?;
    let source_with_call = format!("{}\n{}()", source, action_name);

    let mut vm = VM::new();
    inject_state_into_vm(&mut vm, &state_json);

    let chunk = compile(&source_with_call)?;
    vm.execute(&chunk)?;

    // Redirect back to reload with new data
    redirect(302, &referer_path)
}

The key design decisions:

  • Referer header determines which .flin file to compile. The server does not need a routing table for actions -- it derives the source file from the requesting page.
  • Appending the function call to the source is simple but effective. The full source is compiled (establishing entity definitions), then the function is called at the end.
  • State injection passes browser-side variable values into the VM before execution. When the user typed "Buy milk" in the todo input, the string "Buy milk" is injected as the value of newTodo.
  • 302 redirect reloads the page after the action. The page re-renders with the new data from FlinDB.

The Complete Data Flow

Browser                          Server
   |                               |
   |  1. User types "Buy milk"     |
   |     oninput updates $flin     |
   |                               |
   |  2. User presses Enter        |
   |     _flinSubmit('addTodo')    |
   |                               |
   |  3. POST /_action             |
   |     {action: 'addTodo',       |
   |      state: {newTodo: '...'}} |
   |  --------------------------->  |
   |                               |
   |                               |  4. Compile: source + "addTodo()"
   |                               |  5. Inject state into VM
   |                               |  6. Execute (entity saved to FlinDB)
   |                               |
   |  7. 302 Redirect              |
   |  <---------------------------  |
   |                               |
   |  8. Page reloads              |
   |     New todo visible          |
   |                               |

Session 203: The Persistence Bug

Session 201 fixed interactivity. The counter worked. Inputs bound. Forms submitted. But when the page refreshed, todos disappeared. The WAL file was created but contained 0 bytes.

This was a critical bug: FlinDB appeared to work (the action handler returned 302, the page reloaded, the todo was visible during the redirect cycle) but nothing was persisted. The data lived only in the VM's memory during the action execution and was lost when the VM was destroyed.

Root Cause 1: Bytecode Overwrites Injected State

The dev server injects state variables before bytecode execution:

rustvm.set_global("newTodo", Value::Text("Buy milk".into()));

But the FLIN source declares newTodo = "" at the top level. When the bytecode executes, it overwrites the injected value with an empty string. By the time addTodo() runs, newTodo is empty, the if newTodo.trim() != "" check fails, and nothing is saved.

The fix: protected globals.

rustpub fn set_global_protected(&mut self, name: String, value: Value) {
    self.globals.insert(name.clone(), value);
    self.protected_globals.insert(name);
}

// In OpCode::StoreGlobal:
if !self.protected_globals.contains(&name) {
    self.globals.insert(name, value);
}

Protected globals cannot be overwritten by bytecode. The injected "Buy milk" value survives the newTodo = "" initialization.

Root Cause 2: Value::Text Not Handled by Trim

State injection creates Value::Text("Buy milk"). But OpCode::Trim only handled Value::Object(id) (string objects allocated on the heap). For Value::Text, it returned an empty string.

rust// Before fix:
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    _ => String::new(),  // Value::Text falls here!
};

// After fix:
let s = match &string {
    Value::Object(id) => self.get_string(*id)?.trim().to_string(),
    Value::Text(t) => t.trim().to_string(),  // Handle Value::Text
    _ => String::new(),
};

Two lines. But without them, every string operation on injected state would produce empty results.

Root Cause 3: Silent Validator Failures

The Todo entity had validators @required @min(1) on the title field. The validator was failing silently -- no error was surfaced, but the save operation was quietly aborted.

This was temporarily fixed by removing the validators. The long-term fix (validator error reporting in the action handler) was deferred.

Verification

Before the fix:

bash$ cat embedded/todo-app/.flindb/wal.log
# (empty - 0 bytes)

After the fix:

bash$ cat embedded/todo-app/.flindb/wal.log
{"type":"Save","timestamp":1768567212273,"entity_type":"Todo","entity_id":1,
 "version":1,"data":{"title":{"Object":453}},...}

177 bytes. Data persisting. The WAL now contained the saved Todo entity, and restarting the server recovered it through WAL replay.

The Integration Tests

Session 203 added five tests that mimic the exact server behavior:

  1. test_dev_server_flow_save_entity -- basic entity save
  2. test_dev_server_flow_with_state_injection -- state injection with condition
  3. test_state_injection_without_condition -- isolates state injection
  4. test_recovery_between_vms -- save in VM1, load in VM2
  5. test_entity_queries_after_recovery -- Todo.all and Todo.count after recovery

Test 4 was the most critical. It verified that data saved by one VM instance (the action handler VM) could be loaded by a different VM instance (the page rendering VM). This is the core persistence guarantee -- data must survive across VM lifetimes.

The Lesson

Sessions 201 and 203 taught us something fundamental about database engineering: a database that passes all its tests can still fail in production.

FlinDB had 2,248 tests passing. Every CRUD operation worked. Every constraint was enforced. Every index was utilized. But none of those tests simulated the full round-trip: browser -> form submit -> server action -> state injection -> VM execution -> FlinDB save -> WAL write -> VM destruction -> new VM -> WAL replay -> page render -> browser.

The bugs were not in FlinDB's database layer. They were in the integration points: how the browser communicates with the server, how the server injects state into the VM, how the VM handles different string value types. The database was correct. The glue around it was not.

This is why the testing strategy shifted after Session 203. The five new tests (test_dev_server_flow_*) test the exact sequence of operations that happens when a user interacts with a FLIN application in a browser. They are not database tests. They are system tests. And they caught bugs that 2,248 unit and integration tests missed.

The total test count after Session 203: 2,870 (2,248 library + 617 integration + 5 dev server flow tests).

What Made It Work

Three architectural decisions made browser persistence possible:

First, the WAL-based persistence model. Every mutation is written to a log file. When the action handler VM saves a todo, the WAL entry is immediately flushed to disk. When the page rendering VM starts, it replays the WAL and recovers the saved todo. The WAL is the bridge between VM lifetimes.

Second, server-side rendering. Because FLIN renders on the server, the database operations execute on the server where FlinDB has direct file system access. There is no need to port FlinDB to WebAssembly or use IndexedDB. The browser sends state to the server, the server executes the operation, the server sends back the result. Simple.

Third, the $flin proxy. The client-side JavaScript runtime maintains a proxy object that mirrors server-side state. Two-way binding keeps the proxy in sync with input values. When a form is submitted, the proxy's state is serialized and sent to the server. This ensures that the server VM has the same state that the user saw in the browser.

The result is a database that feels instant from the user's perspective -- type in a todo, press Enter, see it appear -- while running entirely on the server with full ACID guarantees, WAL persistence, and crash recovery. The browser is a thin presentation layer. The database is real.


This is Part 15 of the "How We Built FlinDB" series, documenting how we built a complete embedded database engine for the FLIN programming language.

Series Navigation: - [068] FlinDB Hardening for Production - [069] FlinDB vs SQLite: Why We Built Our Own - [070] Persistence in the Browser (you are here)

This concludes the FlinDB arc. From zero-configuration embedded storage to browser persistence, from entity-first design to EAVT event sourcing, from hash indexes to semantic search -- FlinDB is a complete database engine built for a language that believes data should just work.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles