The default is heavier than the problem
Most small utilities get built with an account system, a database, a session store, and a hosting bill — for a calculator. The reflex is so automatic that nobody asks whether the server is doing anything the browser could not.
For a large class of tools, it is not. A position sizer, a trade journal, a weight tracker, a habit checklist, a unit converter, a text formatter: all of these are pure functions plus a little state. The browser has a JavaScript engine and persistent storage. That is the entire requirement.
Which tools actually need a server
The honest test is not "can this be a website" — almost anything can. It is whether the feature the tool provides is possible without a second party holding state. A decision table, run once per tool, saves the account system nobody needed.
| Tool type | Needs a server | Why |
| Calculator, converter, position sizer | No | Pure function of the inputs. Nothing to persist across a session at all. |
| Trade journal, habit tracker, weight log | No | State is single-user and can live entirely in browser storage. |
| Unit or file conversion (images, text, small media) | No | JavaScript or WebAssembly can do the transform in the browser. |
| Team task board, shared document | Yes | Multiple people must see the same state, which requires a relay. |
| Chat or messaging | Yes | Delivery to someone else's device requires a server in the middle. |
| Marketplace or listings app | Yes | Inventory must stay consistent across every buyer viewing it. |
| Anything calling a paid API with a secret key | Yes | A key embedded in browser JS is public the moment the page loads. |
| Anything that must survive a lost or wiped device | Yes, or strict export discipline | A server-side copy is the only unconditional guarantee. |
| Cross-device sync as a hard requirement | Yes, or an opt-in sync layer | Covered later — this does not have to mean starting over. |
What you gain
No breach surface
The strongest security property available is not holding the data. No user table means no credential dump, no password reset flow to phish, no session tokens to steal, no compliance obligation around personal data you never received. You cannot be forced to hand over records that were never on your server, and an attacker who fully compromises the host gets a folder of static files.
Data stays where the user can see it
Browser storage is inspectable. A curious user can open DevTools and read exactly what the app kept. That is a much stronger claim than a privacy policy, because it is verifiable rather than promised — the network tab shows there are no outbound requests carrying their data.
It costs nothing to run
Static files on GitHub Pages, Netlify, or Cloudflare Pages cost nothing at realistic personal-project traffic. There is no database to back up, no dependency to patch at 2am, no bill that grows with users. A client-side tool that gets popular gets cheaper per user, not more expensive.
It keeps working
No server means no outage, no rate limit, no deprecation. Offline through a service worker, it works on a plane — the mechanics of that are in the offline PWA guide. A tool built this way in 2020 still runs today with no maintenance, which is not a claim most SaaS side projects can make.
Choosing a storage mechanism
"Browser storage" is not one thing. Picking the wrong one shows up later as either a size ceiling or a jank problem, both avoidable up front.
| Mechanism | Persists after tab close | Practical size | Sync to server | Use it for |
| Cookies | Yes | About 4 KB per cookie | Sent automatically on every request | Server communication, not app state — the wrong tool for local-only data |
| sessionStorage | No, cleared on tab close | About 5 MB | None | Transient in-page state, never saved data |
| localStorage | Yes | About 5 MB per origin | None | Small JSON — journals, logs, settings, a few thousand records |
| IndexedDB | Yes | Large, a share of free disk | None | Structured or binary data, real queries, anything past a couple MB |
| Cache API | Yes | Large | None | Network responses and assets behind a service worker, not app data |
| Origin Private File System | Yes | Large | None | Fast binary file access; newer, support still catching up |
What you give up
This is the part most local-first pitches skip, so here it is plainly.
| You lose | What that means in practice |
| Cross-device sync | Data logged on the laptop is not on the phone. Two devices means two separate datasets. |
| Backup | Clearing browser data deletes everything. On iOS, storage can be evicted after long non-use. |
| Server-side compute | No heavy jobs, no scheduled tasks, no calling a paid API without exposing the key. |
| Collaboration | No sharing, no multi-user, no permissions — there is nothing to share through. |
| Analytics on usage | You genuinely do not know how the tool is used, which makes it harder to improve. |
| Recovery | No support ticket can restore what a user deleted, because you never had a copy. |
The honest framing is not that these do not matter. It is that for single-user tools they usually matter less than the ongoing cost of an account system — for the builder and for the user, who has one more password and one more breach notification in their future.
The honest threat model
"Local-only" is not a synonym for "secure." State plainly what the architecture protects against and what it does not — overclaiming here is exactly the kind of promise that erodes trust the moment someone technical tests it.
| Threat | Protected? | Why |
| Server-side data breach | Yes | There is no server-side copy of user data to breach |
| Database credential leak | Yes | There is no database |
| Password reuse or credential stuffing | Yes | There is no password, because there is no account |
| XSS on your own domain | No | A script injected into the page reads localStorage and IndexedDB with the same access your own code has |
| Malicious or compromised browser extension | No | Extensions with broad permissions can read page-accessible storage like any other script |
| Shared or public device | No | Anyone with access to the browser profile can open DevTools and read everything stored |
| Physical device theft, unencrypted disk | No | Browser storage is not encrypted by the browser itself; disk encryption is a separate, OS-level setting |
| Compromised third-party script or CDN dependency | No | A compromised script runs with the same privileges as the rest of the page |
The defence against every "No" row is ordinary web security hygiene, not anything specific to local-first: a strict Content-Security-Policy, subresource integrity on third-party scripts, sanitising anything rendered as HTML, and treating browser extensions as ambient risk outside your control. None of that becomes optional just because there is no server to also secure — the client is now the entire attack surface rather than a thin layer in front of one, which is exactly the case a tool like DIRA is built to scan for.
Mitigations that keep the model intact
- Ship export and import. A JSON or CSV download turns 'no backup' into 'your backup, your file'. This is the single highest-value feature in a local-only tool.
- Warn before destructive actions and never auto-clear storage on version change.
- Make the storage key stable across deploys, and migrate schemas forward rather than resetting them.
- Say where the data lives, in the app, not only in the privacy policy. One line under the form does more than a page nobody opens.
- If sync is genuinely needed later, add it as an opt-in layer over the same local store rather than rewriting around a server.
Schema versioning, worked
"Migrate schemas forward" above is the mitigation people skip, because it sounds like more work than it is. Here is what it looks like end to end, for a tracker that adds a unit field in version 2 that version 1 records never had.
const STORE_KEY = 'cut-tracker';
const CURRENT_VERSION = 2;
const MIGRATIONS = {
1: (data) => data, // baseline, nothing to do
2: (data) => ({
...data,
entries: data.entries.map(e => ({ ...e, unit: e.unit || 'lb' })),
}),
};
function load() {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return { version: CURRENT_VERSION, entries: [] };
let data = JSON.parse(raw);
let v = data.version || 1;
while (v < CURRENT_VERSION) {
v += 1;
data = MIGRATIONS[v](data);
}
if (v !== (data.version || 1)) {
data.version = v;
localStorage.setItem(STORE_KEY, JSON.stringify(data));
}
return data;
}
Two rules make this safe. Migrations run forward, one version at a time, so a jump from version 1 to version 4 replays three small, individually testable steps instead of one large rewrite. And the migration writes back to storage immediately, so the very next load is already current — which means the migration code for an old version can eventually be deleted once you are confident nobody is still on it.
Adding sync later, without a rewrite
The last mitigation above deserves the full explanation, because "add sync later" sounds harder than it is if the local store was treated as the source of truth from day one.
- Local storage stays the source of truth even after sync exists. Sync is a mirror, not a replacement — the app must keep working with the network off.
- Sync is an explicit opt-in, never a silent background upload. A user who never enables it is running the exact same app as before sync existed.
- Sync diffs, not the whole dataset. Timestamp every record on write, and push only what changed since the last successful sync.
- Pick a conflict rule before you need one. Last-write-wins by timestamp is simple and fine for single-user data edited from one device at a time; genuinely concurrent editing needs a CRDT or operational-transform library, which is real added complexity, not a checkbox.
- The server side can be minimal. One authenticated endpoint that accepts and returns a JSON blob per user is enough for years of a single-user tool. It does not need to become a product.
async function sync() {
if (!syncEnabled()) return;
const last = localStorage.getItem('lastSyncAt') || 0;
const dirty = getEntries().filter(e => e.updatedAt > last);
if (dirty.length) {
await fetch('/api/sync', { method: 'POST', body: JSON.stringify({ entries: dirty }) });
}
const remote = await fetch('/api/sync?since=' + last).then(r => r.json());
mergeByTimestamp(remote.entries); // last-write-wins on updatedAt
localStorage.setItem('lastSyncAt', Date.now());
}
Nothing about this touches the local storage schema, the UI, or any code path a non-syncing user exercises. That is the actual payoff of designing local-first from day one: sync becomes an addition, not a rewrite.
What client-side hosting actually costs
The zero-cost claim made earlier is worth pricing out explicitly, against the alternative, as an illustrative comparison rather than a quote from any specific host.
| Host | Free tier | What it costs at real personal-project traffic | Best for |
| GitHub Pages | Unlimited for public repos | Effectively $0 | Personal tools, portfolios, static guides |
| Cloudflare Pages | Unlimited bandwidth on the free plan | Effectively $0 | Same as GitHub Pages, with a faster edge network |
| Netlify | 100 GB bandwidth per month free | $0 until traffic passes the free allowance, then billed per GB | Static tools that also want form handling |
| Vercel | Generous free tier tuned for frameworks | $0 until usage forces an upgrade | If you are already building with a framework it favours |
| Any backend (VM plus managed database) | Rarely a real free tier | Roughly $5 to $50+ per month minimum, before any traffic | Only once a specific feature has proven it needs a server |
The comparison is not close. Static hosting is not merely cheaper than a backend, it is frequently exactly $0 at the traffic a personal tool actually receives, while a backend carries a monthly floor whether or not anyone opens the tool that day.
Writing the privacy policy honestly
A local-only tool makes for a short, unusually credible privacy policy — but only if it is specific. Vague reassurance reads exactly like every policy that turned out to be false.
- Name the storage mechanism: 'stored in your browser's localStorage on this device'.
- State plainly that there is no account, no server-side database, and no transmission of entered data.
- Disclose what is collected, if anything — static hosting still produces server logs with IP addresses, and any embedded analytics or fonts are third-party requests. Do not claim zero collection while loading a tracker.
- Explain the eviction risk: clearing site data deletes it, and browsers may evict storage. Point at the export button in the same sentence.
- Skip the legal-sounding padding. A policy a user can read in 60 seconds is more protective than one they cannot.
The test I use: could a technically literate user verify every claim in the policy from the network tab in under a minute? If yes, it is honest. If a claim requires trusting me, it should be softened or removed. Mine is here →
When you actually do need a backend
Not every tool fits. You need a server when the data is genuinely shared between people, when compute exceeds what a phone browser can do, when an API key must stay secret, when the data must survive a lost device, or when a regulation requires audit trails you can produce. Those are real requirements — the point is to check whether you have one, not to assume you do. Cut, on the stack described here, is the small end of the local-only case: a weight tracker with nothing behind it but a browser.
Tools referenced in this guide
- Apps hub — three tools built this way — no accounts, no server, no analytics on your entries.
- Cut — weight tracker with local storage and an export path.
- Privacy policy — a short, verifiable version of the claims described here.
- My stack — what static hosting and tooling this runs on.