Skip to content

Offline and local first

How local an app can be depends on who decides what the user does: the server, or the device. There are four ways to build it. Two of them work with a domain model today.

The server decides. The app shows a change before the server confirms it, and confirms or rejects it when the answer arrives. See Optimistic updates.

The server still decides, but the app keeps working without a connection. It records what the user asked for, an intent, and never a fact only the server can establish. Intents wait in a queue, survive a reload, and go to the server in order once it can be reached. A conflict is just a rejection: a withdrawal made offline against money that’s gone comes back InsufficientFunds. The rest of this page builds this.

Here the device runs its own copy of an account and records facts in it offline, and everything the user does next builds on them. When the server later rejects one, everything built on it is wrong. Making that safe takes a sync engine that keeps the server’s state apart from pending changes and recomputes everything whenever the server’s state moves. alchemy/Domain doesn’t include one.

Here every device accepts changes and the server only keeps a copy. Nothing can refuse a change, so conflicts must be merged, with CRDTs, last-writer-wins, or rules specific to your data. A domain model assumes one place decides each aggregate, so this calls for a different tool.

The app below extends optimistic updates with a queue. Each line the server hasn’t acknowledged is an intent, saved to localStorage under the customer’s key.

packages/web/src/Bank.ts
import { AccountId, mainAccountId } from "@bank/domain/Account/Account.id";
import { StatementLine } from "@bank/domain/Account/Statement.view";
import { CustomerId } from "@bank/domain/Customer/Customer.id";
import { Username } from "@bank/domain/Customer/Username";
import { CustomerApi } from "@bank/domain/CustomerApi.api";
import type * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { Command, Subscription, type Update } from "foldkit";
import type { Document, HtmlBuilder } from "foldkit/html";
import { defineMessageUnion } from "foldkit/message";
/** The bank client this app runs on, provided by the `resources` Layer in `entry.ts`. */
type Bank = Domain.Api.ClientKey<"CustomerApi">;
/** The demo MFA code `MfaChallenge` accepts. */
const mfa = { headers: { "x-mfa-code": "000000" } };
// INTENTS: what the customer asked for that the server has not acknowledged.
// They are kept across reloads, so asking works offline.
/** A deposit or withdrawal the customer asked for, named by the command that carries it. */
export const Intent = Schema.Struct({
commandId: Schema.String,
kind: Schema.Literals(["deposit", "withdrawal"]),
amount: Schema.Number,
});
export type Intent = typeof Intent.Type;
/** Where a customer's unacknowledged intents are kept between page loads. */
export const intentsKey = (customerId: CustomerId) => `bank:intents:${customerId}`;
// FLAGS: who is signed in, and what this browser still owes the server.
export const Flags = Schema.Struct({
customerId: CustomerId,
/** Unique per page load; with a counter, it names every command this page sends. */
session: Schema.String,
online: Schema.Boolean,
/** Intents saved by an earlier page load, oldest first. */
intents: Schema.Array(Intent),
});
export type Flags = typeof Flags.Type;
// MODEL
/**
* Whether the server has acknowledged a line:
* - `pending`: sent, waiting for the event it causes on the statement stream;
* - `queued`: not sent, because the server could not be reached; sent when back online;
* - `confirmed`: its event arrived;
* - `rejected`: the account refused the command.
*/
export const Ack = Schema.Literals(["pending", "queued", "confirmed", "rejected"]);
export type Ack = typeof Ack.Type;
/** A statement line, shown before the server has acknowledged it. */
export const Line = Schema.Struct({
/** The command that made it. An optimistic line and the event that confirms it share it. */
commandId: Schema.String,
kind: StatementLine.fields.kind,
amount: Schema.Number,
/** The balance after this line; known once it is confirmed. */
balance: Schema.NullOr(Schema.Number),
ack: Ack,
/** Why the account refused it. */
reason: Schema.NullOr(Schema.String),
});
export type Line = typeof Line.Type;
export const Model = Schema.Struct({
customerId: CustomerId,
accountId: AccountId,
session: Schema.String,
/** Commands this page has sent: with `session`, the next command's id. */
sent: Schema.Number,
online: Schema.Boolean,
/** `null` until the main account exists (register to open it). */
balance: Schema.NullOr(Schema.Number),
/** Unacknowledged lines first, then confirmed ones, newest first. */
lines: Schema.Array(Line),
error: Schema.NullOr(Schema.String),
});
export type Model = typeof Model.Type;
/** What delivering one intent came to. */
export const Outcome = Schema.Struct({
commandId: Schema.String,
/** `accepted` waits for its event; `queued` waits for the network. */
result: Schema.Literals(["accepted", "queued", "rejected"]),
reason: Schema.NullOr(Schema.String),
});
export type Outcome = typeof Outcome.Type;
// MESSAGE
export const Message = defineMessageUnion({
ClickedRegister: {},
ClickedDeposit: {},
ClickedWithdraw: {},
SucceededCommand: {},
FailedCommand: { reason: Schema.String },
/** Intents were delivered in order, up to the first that could not reach the server. */
Delivered: { outcomes: Schema.Array(Outcome) },
SavedIntents: {},
WentOnline: {},
WentOffline: {},
GotBalance: { balance: Schema.NullOr(Schema.Number) },
/** A statement event, and the command that caused it. */
GotLine: { commandId: Schema.String, line: StatementLine },
FailedWatch: { reason: Schema.String },
});
export type Message = typeof Message.Type;
// COMMANDS: each runs domain commands over the app's connection.
/** Register the signed-in customer; a policy then opens their main account. */
export const Register = Command.define("Register", {
args: { customerId: CustomerId },
messages: [Message.SucceededCommand, Message.FailedCommand],
execute: ({ customerId }) =>
Effect.gen(function* () {
const bank = yield* CustomerApi.Client;
yield* bank.RegisterCustomer({
target: customerId,
name: customerId,
username: Username.make(customerId),
});
return Message.SucceededCommand();
}).pipe(Effect.catch((error) => Effect.succeed(Message.FailedCommand({ reason: error._tag })))),
});
/**
* Send one intent. A dropped connection is retried briefly, which is safe
* because the account deduplicates by `commandId`: a send that did land is
* not applied twice. If the server still cannot be reached, the intent is
* queued; a rejection is the account's answer.
*/
const deliver = (accountId: AccountId, intent: Intent) =>
Effect.gen(function* () {
const bank = yield* CustomerApi.Client;
const { commandId, amount } = intent;
const send =
intent.kind === "deposit"
? bank.DepositMoney({ target: accountId, amount, commandId })
: bank.WithdrawMoney({ target: accountId, amount, commandId }, mfa);
return yield* send.pipe(
Effect.retry({
while: (error) => error._tag === "RpcClientError",
schedule: Schedule.exponential("200 millis"),
times: 3,
}),
Effect.as<Outcome>({ commandId, result: "accepted", reason: null }),
Effect.catch((error) =>
Effect.succeed<Outcome>(
error._tag === "RpcClientError"
? { commandId, result: "queued", reason: null }
: { commandId, result: "rejected", reason: error._tag },
),
),
);
});
/**
* Deliver intents in the order they were made, stopping at the first that
* cannot reach the server: the rest stay queued, so a withdrawal never
* overtakes the deposit it was made after.
*/
export const Deliver = Command.define("Deliver", {
args: { accountId: AccountId, intents: Schema.Array(Intent) },
messages: [Message.Delivered],
execute: ({ accountId, intents }) =>
Effect.gen(function* () {
const outcomes: Array<Outcome> = [];
for (const intent of intents) {
const outcome = yield* deliver(accountId, intent);
outcomes.push(outcome);
if (outcome.result === "queued") {
for (const rest of intents.slice(outcomes.length)) {
outcomes.push({ commandId: rest.commandId, result: "queued", reason: null });
}
break;
}
}
return Message.Delivered({ outcomes });
}),
});
/** Keep the unacknowledged intents for the next page load. */
export const SaveIntents = Command.define("SaveIntents", {
args: { customerId: CustomerId, intents: Schema.Array(Intent) },
messages: [Message.SavedIntents],
execute: ({ customerId, intents }) =>
Effect.sync(() => {
localStorage.setItem(intentsKey(customerId), JSON.stringify(intents));
return Message.SavedIntents();
}),
});
// UPDATE
/** The next command's id. Unique across page loads, and stable if the command is resent. */
const nextCommandId = (model: Model) => `${model.session}:${model.sent}`;
const isIntent = (line: Line): line is Line & { readonly kind: Intent["kind"] } =>
(line.ack === "pending" || line.ack === "queued") &&
(line.kind === "deposit" || line.kind === "withdrawal");
/** The model's unacknowledged intents, oldest first. */
const intentsOf = (model: Model): Array<Intent> =>
model.lines
.filter(isIntent)
.map(({ commandId, kind, amount }) => ({ commandId, kind, amount }))
.reverse();
const save = (model: Model) =>
SaveIntents({ customerId: model.customerId, intents: intentsOf(model) });
/**
* Add a line the server has not acknowledged, above every other line. Sent
* now if the server is reachable and nothing older is waiting; otherwise
* queued behind what is.
*/
const ask = (
model: Model,
kind: Intent["kind"],
amount: number,
): Update.Return<Model, Message, Bank> => {
const commandId = nextCommandId(model);
const send = model.online && !model.lines.some((l) => l.ack === "queued");
const next: Model = {
...model,
sent: model.sent + 1,
error: null,
lines: [
{ commandId, kind, amount, balance: null, ack: send ? "pending" : "queued", reason: null },
...model.lines,
],
};
return {
model: next,
commands: [
save(next),
...(send
? [Deliver({ accountId: model.accountId, intents: [{ commandId, kind, amount }] })]
: []),
],
};
};
/** Send every queued intent, oldest first. */
const flush = (model: Model): Update.Return<Model, Message, Bank> => {
const queued = intentsOf(model).filter((intent) =>
model.lines.some((l) => l.commandId === intent.commandId && l.ack === "queued"),
);
if (queued.length === 0) return { model };
const sending = new Set(queued.map((intent) => intent.commandId));
return {
model: {
...model,
lines: model.lines.map((l) => (sending.has(l.commandId) ? { ...l, ack: "pending" } : l)),
},
commands: [Deliver({ accountId: model.accountId, intents: queued })],
};
};
/**
* Apply what delivery came to. A confirmed line stays confirmed: its event
* can arrive before the reply.
*/
const delivered = (model: Model, outcomes: ReadonlyArray<Outcome>): Model => {
const byId = new Map(outcomes.map((o) => [o.commandId, o]));
return {
...model,
lines: model.lines.map((l): Line => {
const outcome = byId.get(l.commandId);
if (!outcome || l.ack === "confirmed") return l;
switch (outcome.result) {
case "accepted":
return l;
case "queued":
return { ...l, ack: "queued" };
case "rejected":
return { ...l, ack: "rejected", reason: outcome.reason };
}
}),
};
};
/**
* A statement event arrived. If this browser asked for it, its line becomes
* confirmed; otherwise (history, a transfer, another device) it is a new
* line. Either way it goes above the confirmed lines and below the
* unacknowledged ones.
*/
const confirm = (model: Model, commandId: string, line: StatementLine): Model => {
const confirmed: Line = {
commandId,
kind: line.kind,
amount: line.amount,
balance: line.balance,
ack: "confirmed",
reason: null,
};
const others = model.lines.filter((l) => l.commandId !== commandId);
const unacknowledged = others.filter((l) => l.ack !== "confirmed");
const acknowledged = others.filter((l) => l.ack === "confirmed");
return { ...model, lines: [...unacknowledged, confirmed, ...acknowledged] };
};
export const update = (model: Model, message: Message): Update.Return<Model, Message, Bank> =>
Message.match<Update.Return<Model, Message, Bank>>(message, {
ClickedRegister: () => ({ model, commands: [Register({ customerId: model.customerId })] }),
ClickedDeposit: () => ask(model, "deposit", 1_000),
ClickedWithdraw: () => ask(model, "withdrawal", 1_000),
SucceededCommand: () => ({ model: { ...model, error: null } }),
FailedCommand: ({ reason }) => ({ model: { ...model, error: reason } }),
Delivered: ({ outcomes }) => {
const next = delivered(model, outcomes);
return { model: next, commands: [save(next)] };
},
SavedIntents: () => ({ model }),
WentOnline: () => flush({ ...model, online: true }),
WentOffline: () => ({ model: { ...model, online: false } }),
GotBalance: ({ balance }) => ({ model: { ...model, balance } }),
GotLine: ({ commandId, line }) => {
const next = confirm(model, commandId, line);
return { model: next, commands: [save(next)] };
},
FailedWatch: ({ reason }) => ({ model: { ...model, error: `watch ended: ${reason}` } }),
});
// INIT: the signed-in customer's main account, and what an earlier page load still owes.
export const init = (flags: Flags): Update.Return<Model, Message, Bank> => {
const model: Model = {
customerId: flags.customerId,
accountId: mainAccountId(flags.customerId),
session: flags.session,
sent: 0,
online: flags.online,
balance: null,
// Restored intents are queued: the statement's history confirms those the
// server already applied, and the rest are sent when online.
lines: flags.intents
.map((intent): Line => ({ ...intent, balance: null, ack: "queued", reason: null }))
.reverse(),
error: null,
};
return model.online ? flush(model) : { model };
};
// SUBSCRIPTIONS: connectivity, and the account's summary (snapshots) and
// statement (events), live while online.
export const subscriptions = Subscription.make<Model, Message, Bank>()((entry) => ({
connectivity: entry(
{},
{
modelToDependencies: () => ({}),
dependenciesToStream: () =>
Stream.merge(
Stream.fromEventListener(window, "online").pipe(Stream.as(Message.WentOnline())),
Stream.fromEventListener(window, "offline").pipe(Stream.as(Message.WentOffline())),
),
},
),
// Depending on `online` restarts a watch when the browser comes back,
// however long it was gone.
summary: entry(
{ accountId: AccountId, online: Schema.Boolean },
{
modelToDependencies: (model) => ({ accountId: model.accountId, online: model.online }),
dependenciesToStream: ({ accountId, online }) =>
online
? Stream.unwrap(
Effect.gen(function* () {
const bank = yield* CustomerApi.Client;
return bank.AccountSummary.watch(accountId);
}),
).pipe(
Stream.map((summary) => Message.GotBalance({ balance: summary?.balance ?? null })),
Stream.catch((error) => Stream.succeed(Message.FailedWatch({ reason: error._tag }))),
)
: Stream.empty,
},
),
statement: entry(
{ accountId: AccountId, online: Schema.Boolean },
{
modelToDependencies: (model) => ({ accountId: model.accountId, online: model.online }),
dependenciesToStream: ({ accountId, online }) =>
online
? Stream.unwrap(
Effect.gen(function* () {
const bank = yield* CustomerApi.Client;
return bank.Statement.events(accountId);
}),
).pipe(
Stream.map(({ event, envelope }) =>
Message.GotLine({ commandId: envelope.commandId, line: event }),
),
Stream.catch((error) => Stream.succeed(Message.FailedWatch({ reason: error._tag }))),
)
: Stream.empty,
},
),
}));
// VIEW
/** How an unacknowledged line will move the balance once it is confirmed. */
const signed = (line: Line) =>
line.kind === "deposit" ||
line.kind === "transfer-in" ||
line.kind === "refund" ||
line.kind === "settlement"
? line.amount
: -line.amount;
/**
* The balance to show: the newest confirmed balance (the statement's, so it
* moves with the lines it confirms), plus every unacknowledged line. Nothing
* here is committed, so a line the account later rejects simply stops
* counting.
*/
export const shownBalance = (model: Model): number | null => {
if (model.balance === null) return null;
const confirmed = model.lines.find((l) => l.ack === "confirmed")?.balance ?? model.balance;
return model.lines
.filter((l) => l.ack === "pending" || l.ack === "queued")
.reduce((sum, l) => sum + signed(l), confirmed);
};
const button = "bg-black text-white hover:bg-gray-700 px-4 py-2";
const cents = (amount: number) => (amount / 100).toFixed(2);
const lineView = (line: Line, h: HtmlBuilder<Message>) => {
const text = `${line.kind} ${cents(line.amount)}`;
switch (line.ack) {
case "pending":
return h.li([h.Class("text-gray-400 italic")], [`${text}, sending`]);
case "queued":
return h.li([h.Class("text-amber-600 italic")], [`${text}, queued until online`]);
case "rejected":
return h.li([h.Class("text-red-600 line-through")], [`${text}, rejected: ${line.reason}`]);
case "confirmed":
return h.li([], [`${text}, balance ${cents(line.balance ?? 0)}`]);
}
};
export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
const balance = shownBalance(model);
const unacknowledged = model.lines.some((l) => l.ack === "pending" || l.ack === "queued");
return {
title: balance === null ? "Bank" : `Balance: ${cents(balance)}`,
body: h.div(
[h.Class("flex flex-col gap-4 p-8")],
[
h.p([], [`Signed in as ${model.customerId}`]),
...(model.online
? []
: [
h.p(
[h.Class("text-amber-600")],
["Offline: changes are kept and sent when you reconnect."],
),
]),
...(balance === null
? [h.button([h.OnClick(Message.ClickedRegister()), h.Class(button)], ["Open my account"])]
: [
h.div(
[
h.Class(
unacknowledged ? "text-4xl font-bold text-gray-400" : "text-4xl font-bold",
),
],
[unacknowledged ? `${cents(balance)} (pending)` : cents(balance)],
),
h.div(
[h.Class("flex gap-2")],
[
h.button(
[h.OnClick(Message.ClickedDeposit()), h.Class(button)],
["Deposit 10.00"],
),
h.button(
[h.OnClick(Message.ClickedWithdraw()), h.Class(button)],
["Withdraw 10.00"],
),
],
),
]),
h.p([h.Class("text-red-600")], [model.error ?? ""]),
h.ul(
[],
model.lines.map((line) => lineView(line, h)),
),
],
),
};
};

ask sends an intent only when the browser is online and nothing older is waiting. Otherwise the line is queued, behind the others, so a withdrawal never overtakes the deposit made before it.

Deliver sends intents in order and stops at the first one that can’t reach the server: that one and everything after it stay queued. A typed rejection marks just that line rejected.

Send the queue when the connection returns

Section titled “Send the queue when the connection returns”

The connectivity subscription turns the browser’s online and offline events into messages. WentOnline calls flush, which sends every queued intent, oldest first. The summary and statement subscriptions depend on online, so they restart when the connection returns, however long it was gone.

Every change to the intents saves them with SaveIntents. On the next page load, the entry point reads them back into Flags.

packages/web/src/entry.ts
import { CustomerId } from "@bank/domain/Customer/Customer.id";
import { CustomerApi } from "@bank/domain/CustomerApi.api";
import * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as Layer from "effect/Layer";
import * as OtlpSerialization from "effect/observability/OtlpSerialization";
import * as OtlpTracer from "effect/observability/OtlpTracer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Socket from "effect/socket/Socket";
import { Runtime } from "foldkit";
import { Flags, Intent, init, intentsKey, Model, subscriptions, update, view } from "./Bank.ts";
/**
* The signed-in customer. A demo stand-in for a login: `?customer=c-2` picks
* who you are, defaulting to `c-1`.
*/
const signedIn = Effect.sync(() =>
CustomerId.make(new URLSearchParams(location.search).get("customer") ?? "c-1"),
);
/** Intents an earlier page load could not deliver. Anything unreadable is dropped. */
const savedIntents = (customerId: CustomerId) =>
Effect.sync(() =>
Option.getOrElse(
Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Intent)))(
localStorage.getItem(intentsKey(customerId)) ?? "[]",
),
() => [],
),
);
/** Sign in: the Api sets an `HttpOnly` session cookie on this origin. */
const signIn = Effect.fn(function* (customerId: CustomerId) {
const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
yield* client.execute(
HttpClientRequest.post("/api/session").pipe(HttpClientRequest.bodyJsonUnsafe({ customerId })),
);
});
/**
* The bank Api for the life of the app, on this origin, so the browser sends
* the session cookie with every call and every watch. Commands go over HTTP;
* each watch is a WebSocket to the Durable Object that owns what it reads,
* which pushes every change.
*/
const BankConnection = Layer.unwrap(
Effect.gen(function* () {
// The app cannot run signed out.
yield* signIn(yield* signedIn).pipe(Effect.orDie);
return CustomerApi.layerClient.pipe(
Layer.provide(Domain.protocolWebSocket(`${location.origin}/api`)),
Layer.provide(Socket.layerWebSocketConstructorGlobal),
);
}),
);
/**
* The app's spans, exported to its own origin, where the bank Worker relays
* them on. foldkit traces every Command, and each Api call carries the trace
* on, so a click and everything it causes on the server is one trace.
*/
const Tracing = OtlpTracer.layer({
url: `${location.origin}/api/telemetry/v1/traces`,
resource: { serviceName: "bank-web" },
}).pipe(Layer.provide(OtlpSerialization.layerJson));
const Resources = Layer.mergeAll(BankConnection, Tracing).pipe(
Layer.provide(FetchHttpClient.layer),
);
Runtime.run(
Runtime.makeApplication({
Model,
Flags,
init,
update,
view,
subscriptions,
resources: Resources,
container: document.getElementById("root"),
}),
{
flags: Effect.gen(function* () {
const customerId = yield* signedIn;
return {
customerId,
// Names this page's commands, so each is unique even across reloads.
session: crypto.randomUUID(),
online: navigator.onLine,
intents: yield* savedIntents(customerId),
};
}),
},
);

init restores them as queued and flushes them. The statement replays its history first, and confirms any intent the server already applied. The rest are sent again, and the account recognizes any it already applied by their command id, so none happens twice.

The example’s foldkit stories queue intents offline, send them in order once online, restore them on boot, and show a refused withdrawal no longer counting: see packages/web/test/Bank.test.ts.