Search selfstore
v1.8.22

Supporting an app whose data you have never seen

Server-side, a bug report starts with opening the row. Local-first deletes that step and everything built on it. What replaces the database console and the log search - and the one thing you should never ask a user to send you.

A support ticket for a server-backed app has a well-worn opening move. Somebody writes in to say the total is wrong, you find their account, you open the row, and within a minute you are looking at the same data they are. If that is not enough you search the logs for their request and read the stack trace. If it is a data problem rather than a code problem you fix the row and tell them it is done. None of those steps involves the user doing anything.

All of them disappear in a local-first app, and they disappear together. No row to open: the record is in a browser database on a laptop you will never touch. No request to find: the operation that went wrong never crossed the network. No account to fix, because there is no account. What is left is a person describing a screen to you from memory.

This is a real cost of the architecture and it is usually left out of the pitch. It is also tractable, but only if you decide up front that the app itself is the instrument, because nothing else is going to be.

The trap: making the user do the measuring

The instinct, when the console is gone, is to reach through the user. Send me a screenshot. Open the developer tools and read me what it says in red. Export your data and email me the file.

The first two are slow and lossy: a screenshot shows the symptom and none of the state, and a user talked through a devtools panel has already had a worse day than the bug deserved. The third is the dangerous one. It works, in the sense that it will very likely let you find the bug. It also means the user has just emailed you the entire contents of the app - the thing your whole architecture exists to avoid - and you now hold it, in a mailbox, indefinitely, having promised in your own marketing that you never would.

You will be tempted by it precisely on the hardest tickets, which are the ones where the data is most likely to be sensitive. The decision therefore cannot be made in the moment: it has to be made now, by building the three things the rest of this article describes, so a better option is already sitting there when the moment comes.

Make the app describe itself

The app knows a great deal about its own condition that is not user data, and almost none of it is ever shown. A diagnostics screen, reachable from settings, costs an afternoon and closes a surprising share of tickets on its own: the app version and build, the schema version of the local database, the number of records per collection, whether storage has been granted persistence, how much space the origin uses and how much it was offered, when the last backup was written and where to, when the last sync completed and against which device, and the last handful of errors the app caught.

Two of those come straight from the browser. navigator.storage.estimate() returns usage and quota, and navigator.storage.persisted() says whether the origin is exempt from eviction. Both are one line, and both already answer a whole family of reports that arrive worded as “it lost my data” and are actually eviction under disk pressure.

The important property of this screen is that every field on it is a fact about the container, not the contents. Counts, versions, timestamps, sizes, booleans. A user can screenshot the whole thing and send it to a stranger without disclosing anything, and that is what makes it usable as a support artefact.

The same reasoning applies to error messages, which here are often the only forensic record that will ever exist. “Something went wrong” merely sends you to the logs in a server-backed app; there are no logs, so the message has to carry what they would have. A refusal to open a backup should name the format version in the file and the highest one this build understands. Users copy these into tickets verbatim, so one that names a version and an operation can arrive already solved.

Report the shape of the data, never the data

For anything the diagnostics screen cannot answer you need an error report, and the rule that makes error reporting compatible with local-first is that the report carries structure and never values.

An unhandled exception tells you the stack. The context that actually solves the bug is one layer out: which operation was running, on which collection, at which schema version, on a record with which fields present. All of that can be sent. The field named notes being 4 kilobytes long and of type string is diagnostic. Its contents are not, and never were.

Build the redaction into the reporter, not into the discipline of whoever writes the log line, because discipline does not survive a bad afternoon. A serialiser that can only emit a field name, a type and a length cannot be made to leak a value by a colleague adding one helpful stringify call under deadline. One that emits the object and relies on a deny-list of key names can, and eventually will, on the key somebody added last week.

Then show the user the report before it leaves. Not a checkbox in a privacy policy: the actual payload, rendered, with a send button next to it. An app that says it does not phone home and then shows you exactly what it is offering to send has made a claim you can check in five seconds.

Give yourself something to reproduce on

For the bug you cannot infer from either the app’s description of itself or the shape of its data, you need a way to manufacture data with the same shape as the user’s.

A seed mode does most of the work: it fills a fresh profile with realistic synthetic records, including the awkward ones - the very long name, the empty collection, the record from three schema versions ago, the large table nobody tested against. Better still is an import path that accepts a structural description rather than a document, built from the counts, field shapes and version numbers your error report already collects. That turns a redacted report into a running repro, which is the point of collecting it in a structured form.

The cheaper option is easy to overlook: if your app ships a demo profile, “can you try that on the example file too” costs the user a minute, discloses nothing, and either hands you a reproduction or tells you the bug is in their data rather than your code.

What you are actually trading

You are giving up observability. You will not know your error rate the way a server-backed team knows it, you will hear about a bad release later and less completely, and some bugs will stay unreproduced longer than they would have. Instrumenting your way back to parity is not possible, because the instrumentation is the thing you removed on purpose.

What you get back is that the data was never yours to lose: the support inbox is not a copy of the customer base, and a breach at your end exposes no user records because there are none. That is usually the better side of the trade, but it is a trade, and the way to lose it is to leave the tooling unbuilt and then, one ticket at a time, start asking people to send you their files.


Try selfstore in five minutes, or read the other notes.