Designing edge-cloud systems for unreliable connectivity
What belongs at the edge, what stays in the cloud, and how systems should behave when connectivity fails and each side holds a different version of the truth.

When a warehouse loses its internet connection, the daily report can wait. The scanner on the loading dock is a different story. If it needs a reply from the cloud before it can accept the next pallet, a working scanner and a working local network can still leave an entire shift waiting. The same question comes up on a manufacturing line or at a parking gate: which decisions should depend on a connection to another building, perhaps hundreds of kilometres away?
An edge-first design starts with the decisions that have to happen on site. Give the site enough information to make those decisions locally, and let the cloud handle the work that needs a view across locations. The tricky part is deciding how much the site can safely do with the last update it received.
I ran into that question while building Parqo, a smart parking prototype for my final-year project. Take a driver arriving with a reservation. The gate camera can read the plate, but in a cloud-only design the barrier still waits on this call:
decision = await bookings.claim(site_id, plate, scan_id)If the lot already has a copy of the reservation, it could let the driver in while the connection is down. Now suppose the driver cancelled that booking five minutes ago. The cloud knows about the cancellation. The gate doesn’t. Whether it should open is the question I’ll use to work through the architecture.
Edge-cloud is the architecture. Edge-first is a choice about one operation.
Edge-cloud architecture splits work between computers near the activity and services that coordinate across locations. The edge can be an on-site computer shared by two entrance lanes. The cloud might own reservations for a hundred lots. You can run plate recognition at the edge and still require a cloud call for every admission. That’s already an edge-cloud system, and an internet outage can still stop every car.
I’d call admission edge-first when the on-site service makes the decision on its normal path, including on a good network day. The cloud sends each site the reservations it may honour. Both lanes ask the same local service to claim a reservation. A booking made seconds ago might not have reached the site, so a miss can trigger a bounded refresh. The local service still owns the claim after that refresh.
# Interface sketch. One writer owns claims for this site.
result = await site.claim(scan)
if result.reason == "grant_missing_or_stale":
if await grants.try_refresh(scan.booking_id, timeout_ms=300):
result = await site.claim(scan)
return resultThe 300 milliseconds is an example of a latency budget. The refresh returns false on timeout. An unresolved result keeps entry closed and can send the driver to an attendant. A known, valid booking takes no WAN round trip at all. That’s the gain. The site still depends on its power, local network, disk, and decision service.
I’d draw the ownership boundary before picking a message broker. The cloud can promise a space because it controls bookings. The site can authorise a car against a grant it holds. A passage sensor can report that the car drove through. Those are three different facts. If the barrier jams, the distinction between the last two matters to the person sitting in front of it.
What exactly can the gate trust?
Suppose booking 417 runs until noon. At 09:00 the cloud sends the lot version 12 of that booking. We’ll give the grant a second deadline, 09:15, after which the site must refresh it. The cloud calculates the deadline when it issues the grant:
offline_until = min(booking.ends_at, issued_at + 15 * 60) # UTC secondsNow disconnect the site. At 09:05 the driver cancels online, producing version 13. At 09:10 the same plate appears at the gate. The booking is cancelled in the cloud. The lot still holds a grant it may honour for another five minutes. If your requirements say cancellations must take effect immediately everywhere, offline admission under this policy can’t meet them.
The gate can still give a strong local guarantee: two lanes can’t spend that single-entry reservation twice. Give both lanes one SQLite writer. Keep the cloud’s grant in one table and the site’s admission in another. Here’s the admission table:
CREATE TABLE admissions (
booking_id TEXT PRIMARY KEY,
scan_id TEXT NOT NULL UNIQUE,
plate TEXT NOT NULL,
lane TEXT NOT NULL,
grant_version INTEGER NOT NULL,
approved_at INTEGER NOT NULL,
uploaded INTEGER NOT NULL DEFAULT 0
);Once the site has matched the plate to a candidate booking, it can check eligibility and claim the reservation in one statement:
INSERT INTO admissions
(booking_id, scan_id, plate, lane, grant_version, approved_at)
SELECT booking_id, :scan_id, plate, :lane, version, :now
FROM grants
WHERE booking_id = :booking_id AND plate = :plate AND active = 1
AND starts_at <= :now AND :now < ends_at
AND :now < offline_until
ON CONFLICT (booking_id) DO NOTHING
RETURNING scan_id;Commit before sending approval to the controller. The primary key on booking_id gives one lane the claim. Keeping grant_version answers a question that will come up later: which version did the gate actually use? The row also serves as the pending upload, so a restart after commit leaves a record of the decision.
A lane retrying the same scan can recover its committed decision by scan_id after checking that the booking, plate, and lane match. A fresh scan in the other lane doesn’t get another approval. Replaying a decision also needs care at the controller: a delayed duplicate must never reopen the barrier for a later car. And since the policy uses a deadline, a site that can’t establish the current time after a power cut needs a fresh check or an attendant.
Eventual consistency, as seen from each side
At 09:10, asking the cloud and the gate for “the status of booking 417” gives you two answers. The cloud knows about a cancellation the gate hasn’t seen. The gate knows about an admission the cloud hasn’t seen. Which answer wins?
I’d keep both facts. The cancellation belongs to the booking history. The admission belongs to the site history. Treating them as competing writes to one status field and keeping whichever timestamp is later would throw away information we need to explain what happened.
| Time | Booking service | Parking site |
|---|---|---|
| 09:00 | Booking active, version 12 | Grant version 12 received |
| 09:05, site offline | Cancellation recorded, version 13 | Still has version 12 |
| 09:10 | Has received no admission | Authorises scan 834 under version 12 |
| After successful sync | Cancellation and admission both visible | Version 13 installed, scan 834 still recorded |
This is what eventual consistency means for these records. Once updates stop, connectivity returns, and sync succeeds, both sides can converge on the same booking revision and admission history. There’s no deadline implied by eventual. The 15-minute grant limits how long the site may act on stale data. It makes no promise about when either side will catch up. And convergence can’t undo a physical entry.
Two small pieces of code make that outcome possible. First, upload each admission using the scan ID saved at decision time. On the cloud, make (site_id, scan_id) the primary key. Derive the site ID from the authenticated connection.
INSERT INTO gate_events
(site_id, scan_id, booking_id, plate, lane, grant_version, approved_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (site_id, scan_id) DO NOTHING
RETURNING scan_id;If the link drops after the cloud commits but before the site receives the reply, the site retries with the same payload. On a duplicate, the receiver compares the stored fields with the retry. It acknowledges a matching committed record and flags an ID reused with different contents. Only then does the site mark its row uploaded. It keeps the row itself because it still prevents a second admission.
UPDATE admissions SET uploaded = 1 WHERE scan_id = :acknowledged_scan_id;Second, pull the changes that the cloud owns. In this sketch, a single sync worker fetches an ordered page of changes and applies them in one local transaction:
page = await cloud.booking_changes(after=cursor)
with db:
for change in page.changes:
apply_if_newer(db, change) # Update grants only.
save_cursor(db, page.end_cursor)The cursor advances with the writes it describes. A crash rolls back both, so the page can be fetched again. If the site has missed more history than the cloud retains, it needs a fresh grant snapshot and the cursor for that snapshot. Replacing grants must preserve local admissions. Otherwise downloading an old “active” booking could quietly make a used reservation available again.
Is the trade worth it?
For this lot, the trade is concrete. A longer grant lets more booked cars through a long outage. It also gives a cancelled reservation more time to work. A shorter grant reduces that exposure and sends more legitimate drivers to the booth. If the cloud immediately resells a cancelled space while the old grant is still usable, two drivers may have a claim on the last space. The booking policy has to account for that overlap, for example by delaying resale until the site acknowledges revocation or the grant expires.
I’d test the 09:00 sequence directly. Disconnect the site, cancel version 12 in the cloud, admit a car locally inside the grant window, then reconnect and lose the upload reply once. After the retry and refresh, I’d expect all three assertions to hold:
assert site.grant("417").version == 13
assert site.admission("417").grant_version == 12
assert cloud.event_count(site_id="lagos-01", scan_id="834") == 1If a lot requires immediate revocation and can tolerate waiting for the network, cloud-only admission is a reasonable choice. If admitting known reservations through an outage matters more, edge-first admission buys that capability at a price the operator can understand. For booking 417, that price is the window from 09:05 to 09:15, when the cloud knew about a cancellation and the gate could still say yes. That’s the decision I’d want settled before writing the first line of sync code.