<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rowflare</title>
    <description>The latest articles on DEV Community by Rowflare (@rowflare).</description>
    <link>https://dev.to/rowflare</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202042%2Fd7ae6996-cea9-4f48-8f33-12d695efafb2.png</url>
      <title>DEV Community: Rowflare</title>
      <link>https://dev.to/rowflare</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rowflare"/>
    <language>en</language>
    <item>
      <title>What load tests changed in our open-source Airtable alternative on Durable Objects</title>
      <dc:creator>Rowflare</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:14:54 +0000</pubDate>
      <link>https://dev.to/rowflare/what-load-tests-changed-in-our-open-source-airtable-alternative-on-durable-objects-4ah9</link>
      <guid>https://dev.to/rowflare/what-load-tests-changed-in-our-open-source-airtable-alternative-on-durable-objects-4ah9</guid>
      <description>&lt;p&gt;Rowflare is an open-source (AGPL-3.0) Airtable alternative. The whole app is one Cloudflare Worker plus one Durable Object class: every base gets its own Durable Object, and that object owns a private SQLite database with all of the base's tables, fields, views and rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dot"&gt;&lt;code&gt;&lt;span class="nv"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nv"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;Worker&lt;/span&gt; &lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;auth&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;routing&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;REST&lt;/span&gt; &lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;assets&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nv"&gt;RPC&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;Base&lt;/span&gt; &lt;span class="nv"&gt;Durable&lt;/span&gt; &lt;span class="nv"&gt;Object&lt;/span&gt; &lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;one&lt;/span&gt; &lt;span class="nv"&gt;per&lt;/span&gt; &lt;span class="nv"&gt;base&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;
   &lt;span class="err"&gt;^&lt;/span&gt;                         &lt;span class="err"&gt;|&lt;/span&gt;                                         &lt;span class="err"&gt;|&lt;/span&gt;  &lt;span class="nv"&gt;SQLite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;tables&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fields&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;views&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;rows&lt;/span&gt;
   &lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="o"&gt;------&lt;/span&gt; &lt;span class="nv"&gt;WebSocket&lt;/span&gt; &lt;span class="o"&gt;--------&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="o"&gt;----------------------------------------&lt;/span&gt;&lt;span class="err"&gt;-+&lt;/span&gt;  &lt;span class="nv"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;batch&lt;/span&gt; &lt;span class="nv"&gt;jobs&lt;/span&gt;
                             &lt;span class="nv"&gt;D1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;users&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;workspaces&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;members&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tokens&lt;/span&gt;      &lt;span class="nv"&gt;R2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;attachments&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Worker handles sessions, personal access tokens, workspaces and roles (in D1), the REST API, uploads to R2 and the static web app. The Durable Object applies every write, recomputes dependent fields, pushes changes to connected browsers over WebSocket Hibernation, and runs long jobs from its alarm.&lt;/p&gt;

&lt;p&gt;Most of the interesting decisions were about how to lay out user data inside that one SQLite database. Several of them went the opposite way from what we first assumed, and only load tests settled them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One physical table per user table
&lt;/h2&gt;

&lt;p&gt;The obvious design is a single &lt;code&gt;rows&lt;/code&gt; table with a &lt;code&gt;table_id&lt;/code&gt; column. In load tests it made the SQLite query planner pick unrelated indexes, and filters got 2 to 4 times slower. So each user table is its own physical table (&lt;code&gt;t_1&lt;/code&gt;, &lt;code&gt;t_2&lt;/code&gt;, ...).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Rows are JSON documents keyed by field id
&lt;/h2&gt;

&lt;p&gt;Spreadsheet users add, rename and delete columns all the time. Storing each row as a JSON document keyed by field id means those operations only touch metadata: no &lt;code&gt;ALTER TABLE&lt;/code&gt;, no locking, no column limit. Deleted fields leave keys behind that are ignored on read.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. No expression indexes on the row JSON
&lt;/h2&gt;

&lt;p&gt;Expression indexes look like the natural way to make JSON fields filterable. They are rewritten on every write, though, and in our tests they tripled the rows written and made full-column recomputes 3 times slower. We dropped them, and the aggregation engine folds rows in 1,000-row batches inside the Durable Object instead of running &lt;code&gt;GROUP BY&lt;/code&gt; over JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Links get their own pair tables
&lt;/h2&gt;

&lt;p&gt;A link field is stored twice: in a pair table per link field (&lt;code&gt;l_1(a, b)&lt;/code&gt;, indexed both ways) and mirrored into both cells as &lt;code&gt;[{ id, t }]&lt;/code&gt;, where &lt;code&gt;t&lt;/code&gt; is the cached title the grid displays. Reverse lookups ("which tasks point at this project?") use the index instead of scanning JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping computed fields correct
&lt;/h2&gt;

&lt;p&gt;Lookups, rollups and formulas are materialized into the row, so reads are free and writes only update what depends on the change. Every write goes through one transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Coerce incoming values to each field's stored form.&lt;/li&gt;
&lt;li&gt;For link cells, diff old and new ids, update the pair table and patch the inverse cell on the other side.&lt;/li&gt;
&lt;li&gt;Recompute local computed fields in dependency order, then follow incoming links to rows elsewhere whose lookups, rollups or link titles depend on what changed. Formula cycles are rejected when the formula is saved.&lt;/li&gt;
&lt;li&gt;Flush changed rows and broadcast them to clients (or a "reload" hint when more than 500 rows changed).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Not blocking edits behind big recomputes
&lt;/h2&gt;

&lt;p&gt;Durable Objects are single-threaded. A new formula on a 100,000-row table can take seconds to compute, and nobody else can edit while it runs. Column-wide work therefore goes into a &lt;code&gt;_jobs&lt;/code&gt; table. Tables up to 2,000 rows are processed inside the request; larger tables are processed 500 rows per step from the Durable Object alarm, 4 steps per alarm, so other users' edits slip in between batches. That batching is what keeps a 125,000-row base responsive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some numbers
&lt;/h2&gt;

&lt;p&gt;Measured against the real engine with 50,000 rows, 15 fields, 2,000 linked projects, a lookup, two rollups and a formula (local &lt;code&gt;wrangler dev&lt;/code&gt;, so indicative only):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open a view, first page&lt;/td&gt;
&lt;td&gt;0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit a cell + rollup update&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter by any field&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kanban group counts&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One 500-row recompute batch&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paste 1,000 rows and delete them&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full table, including the numbers that are still unmeasured on production, is in the &lt;a href="https://rowflare.com/docs/architecture/" rel="noopener noreferrer"&gt;architecture docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it outside Cloudflare
&lt;/h2&gt;

&lt;p&gt;The same Worker code also runs in a single Docker container: a small Node entry point starts it in workerd through Miniflare, with Durable Objects, D1 and R2 stored as local files in one volume. It needs no external database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8787:8787 &lt;span class="nt"&gt;-v&lt;/span&gt; rowflare-data:/data rowflare/rowflare:0.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/rowflare/rowflare" rel="noopener noreferrer"&gt;https://github.com/rowflare/rowflare&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Architecture write-up: &lt;a href="https://rowflare.com/docs/architecture/" rel="noopener noreferrer"&gt;https://rowflare.com/docs/architecture/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Site: &lt;a href="https://rowflare.com" rel="noopener noreferrer"&gt;https://rowflare.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure: I'm part of the Rowflare project. Questions about the storage layout or the Durable Object batching are welcome in the comments.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>sqlite</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
