Build the application on the device, not on the network
Most business applications are written as if the network were part of the computer. Take that assumption away — a market in Niamey, a warehouse basement in Lyon, a train, a hospital corridor, a customer who will not let their data leave the building — and what is left is usually a spinner. This is a working application built the other way round, and the whole of it fits in one article.
The assumption worth questioning
Ask where the rules of your application live. Not the screens — the rules. Whether this customer may take more on credit. What price this customer pays. Whether this discount applies. Whether this form is complete.
In most applications those rules live on the server, and the client asks permission for everything. That design has one property that nobody writes in the specification: every rule stops working when the connection does. Not degrades — stops. A sales representative standing in a shop with no signal cannot find out whether the shop may order 40,000 more on credit, so either they promise and hope, or they leave without the order.
The network is not a component of your application. It is a guest, and a guest that is sometimes late and sometimes does not come.
This is obvious to anyone who has shipped software in West Africa, and it is becoming obvious everywhere else for a different reason: customers increasingly want to know that their data is theirs, held on their device or their premises, not because the connection is bad but because it is their data. The two arguments point at exactly the same architecture.
What was built
Route Orders — a field-sales order pad. A representative walks a route of shops, takes orders from a catalogue, and the orders reach the back office later. It is the most ordinary application there is, which is why it was chosen: whatever you build, it probably does these same things.
It is laid out as the web project it is — index.html,
app.css, app.js, orders.ring — and
Ring draws none of it. Ring does not touch the DOM and has never
heard of localStorage. It answers questions; the page decides
how the answers look:
const view = ask("OrderView"); // what does this order come to?
renderOrder(view); // ...and the page decides how that looks
Rewrite app.js in React, Vue or Svelte tomorrow and
orders.ring does not change. That is the boundary the whole
sample is built to show.
In Ring, on the device
- the catalogue and the customer file
- price tiers per customer
- the full-case discount
- tax, at the rate the server sent
- the stock check
- the credit limit
- the order and its lines
- the outbox and its ids
- the sync payload
- reconciling the server's verdicts
- the state written to storage
Everywhere else
- HTML and CSS: the whole interface. Every screen in the sample is ordinary markup and an ordinary stylesheet — the ones you already write.
- JavaScript: the wires —
fetch,localStorage, the DOM, the event handlers. - Your server: two HTTP endpoints that speak JSON — one that hands out reference data, one that accepts finished orders. Django, Laravel, Spring, Rails, Node, Go, .NET or Ring; the device does not know and does not care.
Open it and cut the connection
There is a switch at the top of the page marked Cut the connection. Press it, then keep working: pick a shop, add products, watch the tier price and the case discount apply, watch the credit bar fill, queue the order. Nothing degrades. Restore the line and the queue goes out.
Open Route OrdersThe measurement that makes the argument
The application counts what it does locally and what it sends. A short session — loading the route, searching, taking two orders, one of them entirely offline, then syncing:
| In one session | Count |
|---|---|
| actions performed on the device | 9 |
| times the network was used | 2 |
| total bytes over the wire | 3 KB |
| actions that failed because the line was down | 0 |
Two round trips for a working session. On a metered connection in a rural town that is the difference between an application people use and one they give up on.
The rule that has to be local
Of everything in the application, the credit limit is the one that proves the point. It is a real business constraint with money behind it, it is checked on every order, and a representative cannot phone the office from a market. Here it is, in the Ring that runs on the device:
# RULE: the credit limit. This is the one that has to work with the
# cable out — a representative cannot phone the office from a market.
nRowC = CustomerRowOf(cOrderCustomer)
nLimit = 0 nBalance = 0
if nRowC > 0
nLimit = aCustLimit[nRowC]
nBalance = aCustBalance[nRowC]
ok
nHeadroom = nLimit - nBalance
lBlocked = 0
if nTotal > nHeadroom
lBlocked = 1
ok
Note what happens after an order is queued: the customer's balance goes up immediately, on the device. The second order of the morning is checked against the first one, hours before any server hears about either. That is not a cache. It is the application being the authority on its own work until it hands it over.
The outbox is the reliability story
A finished order does not go to the network. It goes into a queue, with an id the device generated:
cId = cDeviceId + "-" + nNextSeq # REP-014-7
nNextSeq = nNextSeq + 1
aOutId + cId
aOutStatus + "pending"
Three consequences, and each is a bug you do not have to fix later:
A retry cannot double-book. The id was made before the first attempt, so the server can reject a duplicate by id. Idempotency stops being a distributed-systems problem and becomes one line of code.
A send that never arrived is not a send. When the request fails,
everything marked sent goes back to pending and
will be tried again. Nothing is ever in a state where the device thinks it
is done and the server never heard.
One rejection does not lose the other nine. The server answers per order, not per batch — and the device reconciles each verdict, including handing back the credit that a rejected order had provisionally taken.
The two endpoints — this is the whole back-end contract
Your server does not need to know that Ring exists. It needs to answer these two, in JSON.
1 — Reference data. GET /reference. Everything the
device needs to work alone, sent once and refreshed when convenient:
{
"catalogueDate": "2026-08-09",
"currency": "XOF",
"taxRate": 0.19,
"deviceId": "REP-014",
"customers": [
["C-101", "Alimentation Bonkoukou", "Niamey", "A", 900000, 240000]
],
"products": [
["SKU-001", "Rice, long grain 25 kg", "sack", 10, 140, 14500, 15200, 15900]
]
}
Rows, not objects — the same data as {"id": …, "name": …}
costs about four times the memory and fifteen times the load time in a
decoder, for reasons measured here. On a
catalogue this small it does not matter; at twenty thousand rows it is the
difference between instant and eight seconds.
2 — Finished work. POST /orders, with what the
device built:
{ "device": "REP-014", "catalogue": "2026-08-09", "count": 2,
"orders": [ { "id": "REP-014-1", "customer": "C-101",
"total": 163923, "order": "…the priced order…" } ] }
And the answer, one verdict per order:
{ "results": [
{ "id": "REP-014-1", "status": "accepted", "note": "" },
{ "id": "REP-014-2", "status": "rejected",
"note": "account on hold — settle the balance first" } ] }
That is the entire interface. Two endpoints, plain JSON over plain HTTP.
Note catalogue in the payload: the device tells the server
which price list it used, so the server can refuse an order priced against
a stale catalogue. That single field replaces a great deal of anxiety about
clients being out of date.
How the two languages meet
The seam is smaller than people expect. Ring holds the data and the rules; JavaScript holds the wires. They exchange JSON and nothing else:
// JavaScript: the wires
const text = await (await fetch("/reference")).text();
ring.call("RefLoad", text); // hand it to Ring
const v = ring.call("OrderView", 1); // ask Ring for a decision
render(JSON.parse(v.result));
localStorage.setItem(KEY, ring.call("StateExport", 1).result);
Two practical notes that will save you an hour. Every function you call
from the page takes exactly one parameter — that is what
ring.call passes. And an atom key is lowercased on its
way out: write :stillQueued in Ring and JavaScript reads
stillqueued. The sample uses snake_case for every
key it returns, which survives verbatim, so what is written in the Ring file
is exactly what the page receives.
Your back end does not move
This is the honest question, so let it be answered before anything else: nothing here asks you to change your server. If your business logic lives in Django, Laravel, Spring, Rails, .NET, Node or Go, it stays there, untouched, doing what it already does well — persistence, authorisation, reporting, integration, the things a server is for.
What the device needs from it is two endpoints and plain JSON. Your server never learns that Ring exists. There is no bridge to install, no runtime to deploy beside your application, no protocol to adopt. If you can already return JSON — and you can — you are already compatible.
Keep the back end you have. The question this article is asking is only about the other half: what language decides things on the device.
The other half: a front end you can still read next year
The reason local-first projects stall is rarely the idea. It is that the client becomes the hard part. Rules that were three lines on the server turn into state management, a build pipeline, a framework whose major version will move under you, and a second codebase in a second language that must be kept in step with the first — forever.
Here is the credit-limit rule as it is actually written, in the file that runs on the device:
nHeadroom = aCustLimit[nRow] - aCustBalance[nRow]
if nTotal > nHeadroom
lBlocked = 1
ok
A colleague who does not write code can read that and tell you whether it matches the policy. That is not a small property in software that enforces business rules — it is the property that keeps rules correct after the person who wrote them has moved on.
And notice what is not in this project: no npm install,
no node_modules, no bundler, no transpiler, no framework, no
lock file, no build step at all. The application you just ran is three
files served as they are — an HTML page, a Ring file, and a JSON
document — on a runtime of 397 KB with zero dependencies.
Every dependency you do not take is a promise somebody else does not have
to keep for the next ten years.
This is not an argument that JavaScript is bad. It is an argument about where complexity should sit. A field application that must run for years in a branch office, maintained by whoever is there, is exactly the wrong place to put a toolchain that needs attention every quarter.
And none of this is theoretical. The pattern did not start on this website: it comes out of applications built for a bank and a high school in Niger and a restaurant in France — places where the connection is a guest and the software still has to work on Monday morning. The sample above is that experience reduced to something small enough to read in one sitting.
And the runtime is not a toy
RingScript is measured against Lua and QuickJS — two C interpreters compiled to WebAssembly, same weight class — under the same discipline:
| Against the strongest small interpreters | Result |
|---|---|
| heap growth over 10,000 evaluations with errors mixed in | +0.00 MB, as for both peers |
| deaths across 1,200 hostile inputs | 0, as for both peers |
| JSON encode, 8.7 KB | 0.134 ms — ahead of QuickJS's native codec |
| a megabyte through JSON | 1.53 ms, against 14.2 and 154.7 |
| the whole runtime | 397 KB, no dependencies |
It loses rows too, and they are published with their causes on that page. The point is not that Ring wins; it is that a local-first application asks a runtime for endurance and for JSON, and those are exactly the two things this one is strongest at. An application that runs unattended in a branch office for a year needs a heap that does not grow and a parser that does not stall — not a benchmark trophy.
What this is not
It is not a synchronisation framework, and you should be suspicious of anything that claims to be one in four hundred lines. Real conflict resolution — two representatives editing the same customer, merges, vector clocks — is a larger subject, and the honest position is that this pattern handles the append-only case completely and says nothing about the rest. Fortunately, an enormous share of business software is append-only: orders, readings, attendance, deliveries, payments, visits, inspections. If your application is one of those, you already have the whole answer above.
It is also not a claim that everything belongs on the device. Reference data comes from the server because the server owns it. Orders go to the server because the business owns them. What moved is the deciding, and deciding is what stops working when the line does.
Take it apart
| The piece | What to look at |
|---|---|
| The application | run it, cut the connection, read the wire log |
| The project folder | index.html, app.css, app.js, orders.ring — six files, no build step |
| orders.ring | every business rule, and nothing else, in one commented file |
| tests/orders-app.js | the same rules asserted — pricing, credit, outbox, sync, restart |
| The starter kit | one folder, one click, no toolchain |
Everything in this article is running code. If you build business software for people whose connection is a guest — or for customers who want their data to stay theirs — the pattern is worth an afternoon of your time, whichever language your server speaks.