You made a table. You filled it with your own rows. You ran a SELECT and it came back exactly right, and for about ninety seconds you felt like someone who does SQL.
Then you hit refresh, and the table was gone.
No error. No warning. Just an empty page, as if the last hour hadn't happened. If you've searched "sql.js database lost on page refresh", you've been exactly here, and I want to say first: nothing is wrong with you or with your table.
Why it vanished
A sql.js database — sql.js is SQLite compiled to run inside your browser tab, nothing installed — lives in the browser tab's memory. That's the whole trick, and it's why it runs without a server. It's also why closing or refreshing the tab throws it away, the same way a notebook left on a train is gone.
To keep it, you have to do one extra step yourself: turn the database into bytes, put the bytes somewhere the browser keeps between visits, and hand them back to SQLite when the page opens.
That's the boring part before the fun part. It's about fifteen lines.
The plan
One HTML file. One script tag. You paste your own CSV into a box, it becomes a table, you query it, and a Save button keeps it across reloads. No build step, no server to configure.
You'll run the reload test twice, once with the Save step and once without, so you watch the failure with your own eyes. Do not skip the failing run. It's the part that makes the fix stick.
Step 1: build the page
Okay so — save this as 'select-practice.html'. Then open it in your browser. If it fails to load, check your connection first, because the sql.js script comes from a CDN (a content-delivery network — a public web address the browser downloads the library from).
<!doctype html> <meta charset="utf-8"> <title>SELECT practice</title> <script src="https://cdn.jsdelivr.net/npm/sql.js@1.15.1/dist/sql-wasm.js"></script> <textarea id="csv" rows="5" cols="40">name,city,age Ada,London,36 Grace,New York,45 Linus,Portland,55</textarea><br> <button id="load">Load CSV into table people</button><br> <input id="sql" size="40" value="SELECT * FROM people WHERE age > 40"> <button id="run">Run</button> <button id="save">Save</button> <p id="status"></p><pre id="out"></pre> <script> const $ = (id) => document.getElementById(id); // One IndexedDB store, one key, holding the database file's bytes. function store(mode, fn) { return new Promise((resolve, reject) => { const open = indexedDB.open('sql-practice', 1); open.onupgradeneeded = () => open.result.createObjectStore('files'); open.onerror = () => reject(open.error); open.onsuccess = () => { const tx = open.result.transaction('files', mode); const req = fn(tx.objectStore('files')); tx.oncomplete = () => resolve(req.result); tx.onerror = () => reject(tx.error); }; }); } let db; async function start() { const SQL = await initSqlJs({ locateFile: (f) => 'https://cdn.jsdelivr.net/npm/sql.js@1.15.1/dist/' + f }); const saved = await store('readonly', (s) => s.get('main')); // undefined if never saved db = saved ? new SQL.Database(saved) : new SQL.Database(); $('status').textContent = saved ? 'Restored ' + saved.length + ' saved bytes.' : 'No saved copy found. Brand-new empty database.'; } $('load').onclick = () => { const rows = $('csv').value.trim().split('\n').map((l) => l.split(',').map((c) => c.trim())); const cols = rows[0].map((c) => '"' + c + '" NUMERIC'); // NUMERIC turns '36' into 36 db.run('DROP TABLE IF EXISTS people'); db.run('CREATE TABLE people (' + cols.join(', ') + ')'); for (const r of rows.slice(1)) db.run('INSERT INTO people VALUES (' + r.map(() => '?') + ')', r); $('status').textContent = 'Loaded ' + (rows.length - 1) + ' rows. Not saved yet.'; }; $('run').onclick = () => { try { const [res] = db.exec($('sql').value); $('out').textContent = res ? [res.columns.join(' | ')].concat(res.values.map((r) => r.join(' | '))).join('\n') : '(no rows)'; } catch (e) { $('out').textContent = 'Error: ' + e.message; } }; $('save').onclick = async () => { await store('readwrite', (s) => s.put(db.export(), 'main')); // export() = Uint8Array $('status').textContent = 'Saved. Safe to reload.'; }; start(); </script>
The sql.js version here (1.15.1) is the latest on npm as of 22 October 2026. That package's 'dist' folder holds both 'sql-wasm.js' and 'sql-wasm.wasm', which the two URLs point at. I couldn't fetch the CDN itself from my machine, so if the page won't load, check that path first.
Two lines are worth a second look before you move on. 'db.export()' hands back a 'Uint8Array', which is the entire database file as a list of bytes. And 'new SQL.Database(saved)' takes that same list and rebuilds the database from it.
The 'NUMERIC' after each column name is there so '36' becomes the number 36 and 'WHERE age > 40' compares numbers, not text. The catch: a zip code like 02134 comes back as 2134. When I tested it, the leading zero was gone. Shipped ugly, and fine for practice. Change it if your data cares.
Step 2: load your own rows
Delete my three sample people and paste your own CSV. First line is the column names. Keep it simple: no commas inside a value, because this parser just splits on commas. A list of your books, your expenses, your football results, anything with a header row.
Press Load. Then change the SQL box to something you're curious about and press Run. Try ORDER BY, try COUNT, try a WHERE on a column you made up. Two things to know as you go: pressing Load again drops the existing people table and rebuilds it from the box, and pressing Save overwrites the whole saved file rather than merging changes in. For practice that's exactly what you want.
That's the fun part, and it already works.
Step 3: the reload test, without saving
Do not press Save. Refresh the page.
The status line now says "No saved copy found. Brand-new empty database." Press Run on the same query and you get 'Error: no such table: people'.
Here's what I want you to notice. The page started perfectly happily. Nothing red appeared on load. The code asked "is there a saved copy?", got back nothing, and quietly made a fresh empty database instead. The only evidence is one line of grey text. If I hadn't printed that line, you'd have no idea which of those two things happened.
There's a sneakier version. Press Save on a brand-new page before loading any rows. I checked, and 'db.export()' on a database with nothing in it returns zero bytes. Reload and the status says "Restored 0 saved bytes", then your table is missing again. You saved. It just saved nothing. That message comes straight from the saved ? 'Restored ...' : ... line in start(): an empty byte array is still a truthy saved value, so the code takes the "Restored" branch and cheerfully reports zero. If you want to see the bytes for yourself, open your browser's dev tools, then Application, then IndexedDB, then sql-practice, then files, then main.
And if the bytes are garbage, sql.js does complain: 'file is not a database'. So the restore step fails loudly on corrupt data and silently on missing data. Print which one you got.
Step 4: the reload test, with saving
Load your CSV again. Press Save, wait for "Saved. Safe to reload.", then refresh.
The status line now reads "Restored 8192 saved bytes." (That number is the file size for these three sample rows; with your own data it will differ.) Run your query. Your rows are back.
That's the round trip: rows into a table, table into bytes with 'db.export()', bytes into IndexedDB, and on the next visit, bytes back into 'new SQL.Database(saved)'.
IndexedDB is the browser's own storage for larger things like files, and it outlives a reload.
Now the honest part. I ran this page's logic in Node with sql.js 1.15.1 and a stand-in for IndexedDB, reloading it by starting the script again. I did not run it in a real browser. So your reload is the real test. If your numbers differ from mine, trust your screen, then tell me.
"It worked" and "it kept" are two different claims
The reason this is worth ten minutes isn't the Save button. It's the habit underneath it: when something runs in memory, "it worked" and "it kept" are two separate claims, and you have to test the second one on purpose.
Every program you write will tempt you to check only the first. The reload is a cheap way to check both. You'll use it on databases, on forms, on anything an AI tool generates that claims to "save your data".
Also: you now have a place to practice SELECT on rows you actually care about, which beats a canned table of fictional employees.
What I left out on purpose
There's a more serious way to persist a browser database: the origin private file system, which lets SQLite write straight to a file in the browser instead of holding everything in memory. I didn't use it because, as far as I understand it, it runs in a Worker and needs a bundled build, which means more than one file. Some setups also want special server headers. I couldn't read the official SQLite WebAssembly persistence page from my machine, so check that page directly before you lean on any of this.
Our manual Save is a hack, not a best practice. It rewrites the whole database every time, which is fine for a few hundred rows and silly for a million. For practice, it's perfect.
Final thought
Do the failing run on purpose, tonight. Load your CSV, skip Save, refresh, and read the status line aloud. Then press Save and do it again.
Once you've watched the same refresh both lose and keep your rows, you'll never wonder where your data went again.