A copy of Airtable's grid: bases, tables, text and number columns, and a spreadsheet you can drive from the keyboard. The hard part was size. The app has a button that adds 100,000 rows to a table, and the last two days of work went on making a table that big scroll without freezing the tab.
A 100,000-row table with one cell selected, filled by my own seed script because the app's generator stopped partway (more below). All screenshots are from a local demo account with seeded data, on the January build. A personal clone, not affiliated with Airtable; the logo in the app is Airtable's, via Icons8.
100,000
rows in one table
0.42 s
jump to row 50,000
63
rows drawn at most
Context
I built it alone between 5 and 17 January 2026, in 86 commits. Filters, sorts and views were next, and never got built.
Four ways to load rows
I wrote the grid's row loading four times, each version aimed at the previous one's worst problem. Offset paging asks for “skip 50,000 rows, then give me 100”; cursor paging asks for “the 100 rows after this one”.
9 Janoffset paging, 100 rows a page
16 Janinfinite scroll, 50 rows a page, cursor on the row id
17 Jan500 rows a page, cursor on row order, next page at 50% scrolled
17 Janfixed-height window: one count, 100-row pages by order range
The third version's commit message says “lag is still persistent”, and the numbers agree. I rebuilt the current code with that commit's loader swapped back in, and scrolled both builds down the same 100,000-row table in Chrome. The older grid's virtualiser still drew every row it had loaded: by row 35,000 it had 35,501 rows drawn in the page and 1,257 MB of JavaScript heap. After ten minutes it still had not reached row 50,000, and not a single frame completed in the five seconds of scrolling after that.
Memory by row reached in a 100,000-row table
Chrome JavaScript heap after garbage collection, with that row on screen
17 Jan loadercurrent grid
view data
row on screen
17 Jan loader
current grid
top
53.0 MB
11.4 MB
5k
194.1 MB
14.0 MB
10k
374.7 MB
16.7 MB
15k
547.2 MB
19.2 MB
20k
728.3 MB
21.6 MB
25k
899.7 MB
23.7 MB
30k
1,075.3 MB
26.2 MB
35k
1,257.0 MB
28.5 MB
Local production builds, same table, headless Chrome. The 17 Jan loader was scrolled down to each row; the current grid jumped to each row in turn.
In my 900 px-tall browser window, the current grid drew 43 to 63 rows wherever I was in the table. Pages that have not arrived yet show as grey skeleton rows. Jumping straight to row 50,000 took 0.42 s. In five seconds of steady scrolling from there, in headless Chrome, the slowest frame took 34 ms and the middle of the screen never showed a skeleton.
Mid-jump: skeleton rows hold the space while the pages from row 61,841 load. I delayed the responses by four seconds for this shot; normally the skeletons are gone in a fraction of a second.
Rows 73,413 onward, a moment after a jump.
On the server, the stored position does the work. A page is “positions 50,000 to 50,099”, which is one indexed lookup however deep it sits. Offset paging has to skip rows first, so it slows down with depth, though at this size only by a few milliseconds.
Time to fetch 100 rows, by how deep they sit
The app's own tRPC procedures against local Postgres, median of 7
offsetorder cursororder range
view data
first row of the page
offset
order cursor
order range
0
5.6 ms
6.1 ms
6.1 ms
10k
7.1 ms
6.1 ms
6.3 ms
25k
6.1 ms
4.6 ms
4.9 ms
50k
7.4 ms
4.5 ms
4.1 ms
75k
8.6 ms
4.4 ms
4.3 ms
99.9k
10.3 ms
4.6 ms
6.0 ms
One run; a second saved run shows the same trend. Offset paging slows with depth, but at 100,000 rows the gap is a few milliseconds.
Data model
A table's columns are rows in the database too, and every value is a row of its own.
Basea workspace, with a colour, owned by one user
Tablebelongs to a base
Columnname, TEXT or NUMBER, and its position
Rowjust an id and its position
Cellone per row and column: a text or a number value
Key technical decisions
a fixed-height window over infinite scroll
Infinite scroll only knows the rows it has fetched, so the scrollbar is wrong and reaching the end means loading everything before it. Here one count query sizes the scrollbar for the whole table, and the grid fetches only the pages in view plus two either side. Its own cache holds at most 10 pages and drops the least recently used.
order ranges over offset paging
Every row stores its position, and the table and position are indexed together. Any page is one range query on that index, plus its cells, so pages can load in any order and in parallel. On the server, reaching row 50,000 with the old 500-row pages took about 1.9 s of requests in sequence; a count, then five range pages in parallel, took 23 to 32 ms.
a row per cell over a JSON column per row
Each value is its own Cell, unique per row and column, with indexes on column and value. Columns are data, so adding one needs no schema change. The price is volume: 100,000 rows of 7 columns is 700,000 cells, and adding a column to a 40,003-row table wrote a cell for every row, which took 5.2 s.
two keyboard modes over inputs that are always live
In grid mode the arrow keys and Tab move the selection, and typing, Enter or Backspace starts editing. In edit mode Enter saves, Escape cancels, and Tab saves and moves to the next cell, wrapping to the next row.
ownership checks on the server over trusting ids from the browser
There are 22 API calls, not counting the starter template's sample. 21 of them check that every base, table, column, row or cell they touch belongs to the signed-in user. The odd one out is below.
Four rows picked with a Shift-click, and an edit in progress in row 10.
Timeline
5–9 Jan 2026
Sign-in, bases and tables
T3 scaffold, Better Auth with email, GitHub and Google sign-in, a Postman collection for the API, bases with colours, and a views sidebar with placeholder content.
10–14 Jan 2026
The grid
A virtualised table with keyboard navigation and cell editing, then renaming, deleting and dragging columns into a new order.
16–17 Jan 2026
100,000 rows
Row selection and deletion, the 100,000-row generator, and three rewrites of how rows load, ending with the fixed-height window. The only later commits update the Postman collection.
On a phone. The layout is desktop-first: the sidebar keeps its width and the grid gets what is left.
What I'd fix next
Filters, sorts and views. The schema has a View model with filters, sorts and hidden columns, and the cell indexes to back them, but no screen or route uses it. The one sort I had lived in the older table and went when the grid was rebuilt.
Deleting rows. Positions are never renumbered, and the grid finds the row for screen position n by looking for position n. In my test, deleting the third of five rows left a skeleton in its place and hid the last row, even after a reload.
Column reordering. It checks that the table is yours, then saves new positions for whatever column ids the browser sends, without checking that they belong to that table.
The generator. It writes 5,000 rows per database transaction, which is 35,000 cells on a 7-column table. On my laptop, busy with other work, one batch took 5.2 s, past the database library's 5-second limit per transaction, and the run stopped at 35,000 of 100,000 rows.
Memory. The current grid's heap still climbs by about 2.4 MB per jump, even though its own cache drops old pages. My guess is TanStack Query, which keeps every fetched page for five minutes by default.
Tests. There are none yet, and tests are still on the TODO list.