Local-Second, Event-Driven Webapps
You're on your way to a family vacation in Italy, driving from Poland, and it's a long drive of over 1500 km, so you decide to make a stopover in Austria. After an entire day behind the wheel, you arrive at the hotel, eager to stretch your legs, give the kids an occasion to run around, and explore what the Austrian city has to offer.
You enter the hotel, happy that the driving for today is over, and walk over to the front desk. But here the problems start! The Internet connection is down, and they can't check you in. "But," you say, "I've got everything in here, the booking, the confirmation, can't you just give me a key to my room and complete the check-in later?". "No," they reply, "checking in through the System is the only way to get a key". The System is, of course, an ordinary in-browser web app.
They ask you to wait. You do so; 10 minutes pass, 20 minutes—the kids are starting to ruin the lobby—finally, 30 minutes later, the connection is back online, and you check in successfully. On one hand, you might say, it's only 30 minutes. On the other hand, you've started programming at around age 12, and you can't help but wonder—are we doomed to live with such technology? Or can we do better?
Of course we can! It's just poor (but probably cheap) system design that provides "sub-optimal"—to put it mildly—user experience in case of network problems.
Local-first?
I've been a fan of the local-first approach for a long time. It's full of good ideas which feel "right": you own your data; a central server is a convenience, not a necessity; the application is resilient to disruptions. Hence, I thought—maybe that's the solution to the hotel check-in problem?
When driving the next day with plenty of time to think, a full-blown local-first app for this type of problem would probably be too much. The tools and techniques are available, such as CRDTs and automerge, but they excel in areas like collaborative editing and mobile apps with intermittent connectivity.
But we can take some of the ideas and apply them to create a "local-second" solution. By default, the system would operate in a traditional, centralized client-server fashion. That is, a completely standard webapp+HTTP API.
However, when there are network problems, we would degrade into an offline mode, which offers limited functionality. The crucial services are still available—and data is being buffered until the connection is restored.
In our case, such crucial functionality is checking guests in, so that they can rest after a long trip; other operations might be unavailable.
The event-driven PoC
Armed with AI coding assistants (Claude Code, if you're curious), I've created a PoC application, which implements a skeleton of such a local-second webapp and backend. But before diving into the code, there are a couple of choices to be made regarding the technologies used and the architecture itself.
Unlike in a traditional web app, in addition to the fully-controlled & centralized logic that is happening on the server, here we occasionally (during offline episodes) end up with modifications to the data being done fully on the client side, and synchronized only later.
To simplify reasoning about such a setup, I decided to rely on events. The backend is event-driven: events constitute the primary source of truth, and are persisted, providing an audit trail and a way to create projections for fast queries.
Event sourcing is often tied to NoSQL databases, the CQRS pattern, and suffers from complexity due to eventual consistency. But it doesn't have to be that way; you can implement event sourcing on top of a relational database, and enjoy the benefits of atomic transactions.
That's the approach I took: transactional event sourcing, on top of PostgreSQL, with always-consistent projections, as they are updated in the same transaction as inserting the event.
If you're curious about the details of this architecture, I've described it in a separate article: "Implementing event sourcing using a relational database". This is complemented by a template solution on how you can structure atomic projection updates & implement transactional event listeners, in the article "Entry-level, synchronous & transactional event sourcing".
When used in online mode, the app operates as a completely regular, boring, CRUD web app. You don't see the events as a user of the backend HTTP API. We've got /checkin
, /checkout
, POST /bookings
(creating a booking) endpoints. These generate events internally and update the relevant projections. The current bookings can be retrieved using a regular GET /bookings
request, which queries a table created as a projection of the events. So why go into the additional trouble of having the events?
radically simplifies the architecture and how you think about the system when offline mode kicks in. During degraded operations, we need to be able to check in guests locally—buffering the modifications (such as room assignment). When the system goes back online, it needs to send every such modification to the backend, where it has to be applied to the database. And the backend (assuming the client is authenticated correctly) has always to accept such requests: events are immutable facts; the check-in has already happened.
Hence, we accumulate offline check-ins as client-side events. When the system goes back online, they are sent one-by-one to the backend. The backend then translates a client-side event into a server-side event, and handles it as it would handle a "normally" generated event.
The logic is nicely encapsulated in the event handlers, which are triggered both when an event is generated through an online HTTP API invocation and when an event is generated through the offline catch-up mechanism. Moreover, both the client and server use the same notion of events, representing facts that happened in the system, providing a consistent framework to reason about the application.
Technicalities, and syncing backend data to frontend
We still haven't chosen the technologies! For the PoC, on the frontend, I went with React+Typescript. On the backend, I decided to go with Rust+Axum+sqlx (because—why not?). Any backend language that can talk to PostgreSQL would do, and Rust is not only fast, but also strongly type-checked, which definitely appeals to my taste.
As a side quest, I wanted to see how Claude Code fares when tasked with implementing a non-mainstream backend pattern (transactional event sourcing), using the above-mentioned articles as a guide, in a language on which it wasn't predominantly trained, and which isn't a first choice for CRUD backends. The outcome? I'll give a full report in next month's This Month We AIed, but as a spoiler: it did pretty well, almost the entire backend code is AI-generated, but it did require detailed supervision.
There are two frontend applications, one for guests to book hotels, the other for front-desk staff to check guests in and out. It's a very bare-bones implementation, more of a skeleton; it lacks essential features, such as any form of security.
There's one more piece of functionality that is not yet covered, which is synchronizing the state of the backend with the state of the frontend. Typically, a front-desk clerk would have the application open at all times—hence, we need to synchronize the state of the backend to the frontend continuously.
As always, there are a couple of good ways to implement this. The frontend could, for example, establish a WebSocket connection to the backend, receive a stream of events (not necessarily the same events as the backend persists, but some transformation of them), and apply them locally. Alternatively, it could periodically query for today's bookings. Or …
Well, there are a couple of frontend solutions that are dedicated to local-first applications, so that's where I started looking. For example, we've got RxDB, which is a local-first JavaScript database and offers functionality to synchronize data to the backend. Looks tempting, but on a closer look, it's more tailored towards "full" local-first applications, not necessarily the best choice for our local-second approach. With RxDB, data is predominantly managed by the client, conflicts are resolved client-side as well, before being accepted as writes on the server. An interesting technology, but not the best fit.
Another solution is ElectricSQL, which offers a way to synchronize a PostgreSQL query into a JavaScript app continuously. It runs as a stand-alone application next to your backend, but it's not meant to be used directly from the client. Instead, your backend should be a proxy, handling authentication and authorization, before delegating a query to the Electric instance.
On the frontend, it relies on long-polling requests, to continuously receive updates to the data. That's exactly what we need here!
Electric doesn't offer any built-in solutions for synchronizing local writes to the backend, but documents a couple of patterns which you might use.
Our event-driven approach is close to "shared persistent optimistic state", but provides a more general solution to the problem of storing local writes. It consistently maps client-side events to server-side concepts, maintaining a high level of abstraction, rather than working on the level of accumulating low-level insert
/update
operations.
ElectricSQL in practice
How is ElectricSQL used in the PoC? The core concept in Electric is a shape
, which is a query that is synchronized with the frontend. The Rust backend app exposes a /hotels/{id}/bookings/shape
endpoint, which proxies to the locally running Electric instance with a specific query. Hence, the client cannot execute arbitrary queries in a streaming fashion, only a single, fixed one.
In our case, that query selects data from the bookings
projection, which is transactionally updated when any booking-related events are handled. Whenever bookings for the current day change, all connected clients immediately receive the data, which is then displayed using normal React updates.
As a downside, using Electric as we do in the PoC results in a small eventual consistency gap. For example, when checking out a guest, the request is first handled by the backend: an event is generated, and the bookings projection is updated. This completes the request, and the client-side interaction is done.
What's important to note is that in architectures such as CQRS, the eventual consistency gap is often present on the server. Here, we've got eventual consistency on the client. It might, or might not be an important distinction. And it's not a problem in this application, but might be in others; something to keep in mind.
Electric then picks up this change, and the modifications are pushed to the clients, updating the view. Under normal circumstances, the update seems instant; however, there might be a small gap between completing the HTTP API request and receiving the update.
As an upside, when offline events are synchronized to the backend (after it comes back online), we don't have to do anything on the frontend—the view will simply update after receiving new data from the bookings shape. This simplifies the synchronisation logic significantly.
Offline support
We're almost there with implementing our local-second webapp, but one detail is missing. Things work fine when the Internet connection is down: the webapp enters a degraded mode and allows only offline check-ins until connectivity is restored.
However, what if the front-desk clerk hits refresh, and the app is reloaded? Refreshing is almost a reflex when there are any problems. Well, they would be greeted with a "This site can't be reached" screen, instead of our web app.
The solution here is to implement a Progressive Web App, or PWA for short. It's a standard that's now quite widely implemented, and allows caching of resources and query results so that they can be retrieved when offline. That's precisely what we need!
Enabling PWA for our app is quite simple: it amounts to installing the vite-pwa
plugin, and providing some configuration (such as … an app icon, which is required, otherwise the PWA app won't work "silently", that is, without emitting any errors). We need to enumerate the resources which should be offline-cached (in our case, all html
, css
, js
files). You can specify whether these resources should be network-first, meaning the browser always checks for newer versions before reverting to the cached ones, or local-first, where the browser always uses locally cached resources for faster loading times, albeit at the cost of worse upgradeability. In the PoC hotel application, every resource is network-first.
Moreover, we need to specify the backend queries for which results should be cached. In our case, these are the queries to get the list of hotels (which is presented to the clerk when starting to use the app), and the details of the chosen hotel (name, number of rooms).
What will not work is caching the results of Electric's bookings shape. That's because these are incremental, and we are interested in the combined results (initial query results + updates), not in the latest data that is received. That's why we need to cache what the Electric React hook yields (which is always the latest, combined data) using the browser's local storage.
No centralized server
When creating such a local-second application (where the primary mode of operation is traditional client-server, falling back to a local-only mode when offline), there are a couple of things to consider, as we no longer have a central server to validate and coordinate all data mutations.
For example, it's possible that two people are checked into the same room. We should somehow accommodate such a situation. This is not implemented in the PoC, but the backend should accept such conflicting check-in requests. Then, the clerks should be presented with a visual indication of the situation, resolve it manually, and have the option to re-check-in the guest to another room.
That's also a consequence of the event-driven approach: once you have an event, you have to treat it as a fact; you can't discuss it. It already happened, now you can only try to rescue the situation (this could result in creating compensating events).
What if there are multiple check-in desks? Well, when working in degraded mode, either only one should be operational, or the clerks would have to coordinate manually which rooms they choose for their guests.
Ideally, we could imagine having the local web apps talking to each other over the local network. This isn't possible due to the browser's security model. You could try to use WebRTC, which provides local connectivity, but still requires a central signalling server, which coordinates the initial connections. Yet another option would be to run a local off-browser server, which communicates with other local clients and maintains a standard view of offline events.
TL;DR
Network connectivity problems are a fact of life, either due to service outages or local problems with Internet providers. Possibly, hotel check-ins aren't necessarily that critical to justify the additional system complexity, but on the other hand, shouldn't the user experience have priority, not the technical limitations of the hotel's system?
Either way, a local-second app tries to combine the convenience of having a centralized database and server, which coordinates the execution of the system's logic, avoid conflicts and keeps everybody up-to-date; with providing the core functionality also when offline, by implementing a "degraded mode", which exposes only the critical logic to be run purely on the client-side, and synchronized later.
Basing both the server & client logic on events provides a consistent framework for understanding and thinking about the system. Events are immutable facts that always have to be handled by the system. Transactional event sourcing allows one to have the benefits of event sourcing, and the convenience of being able to run SQL queries on always-consistent projections. Using events universally enables implementing synchronization of local writes to a server while maintaining a high level of abstraction.
Finally, ElectricSQL provides a clean way to synchronize backend database changes to the frontend, regardless of whether the mutations happened online or when catching up after an offline episode.
Here's a short screencast of the application at work. Notice that when online, the rooms are assigned by the server. When offline, a manual room selection screen appears; the changes are synchronized to other instances of the front-desk application when it comes back online:
The entire application is available on GitHub, runnable locally. Did you encounter similar problems in the applications you worked on? How did you solve them?
Reviewed by Krzysztof Grajek, Michał Matłoka—thank you!