Counting offline: mobile terminal architecture
Count night: six terminals, three people, fourteen thousand lines. At 23:40 the wireless drops in the warehouse aisle and the last twenty minutes of scans are lost on two terminals. In field applications that scenario is not the exception, it is the default. When offline operation is designed as a feature added later, this is how it ends. When it is taken as an architectural premise from the start, the count finishes without a network.
Why there is no network in a warehouse
The problem is usually not the infrastructure budget. A warehouse is a hostile environment for radio waves. Metal racking reflects and absorbs signal. Cold store insulation is made of thick metal panels. Access points do not reach basement stock areas. In a high-bay warehouse the access points hang from the ceiling and a terminal working on the lower racks sits in shadow.
The second source is movement. The person counting does not stand still; they walk the aisle and hand over between access points continuously. During the handover the connection drops for a second or two. An app that tries to write to the server on every scan loses data in those two seconds, or makes the user wait.
The third source is timing. Counts are usually done outside working hours, at night or at the weekend. There is no network team, nobody to intervene when something goes wrong, and the cost of postponing the count is high.
The shared consequence of those three: the app must be designed assuming there is no network, and treat the network as an opportunity when it appears.
The real contract of working offline
In most products, 'works offline' means holding data in memory. Close the app or restart the device and the data is gone. The real contract is stricter.
When the user scans a barcode the app writes the data to the device's persistent storage, and does not show the user a confirmation before that write completes. That write has to finish in under twenty milliseconds; otherwise scanning speed drops and the user starts waiting on the app. A user who waits breaks their counting discipline and skips scans.
The confirmation should not be visual only. Someone counting looks at the rack, not the screen. A short vibration and an audible cue are the only reliable way to signal that the scan was recorded.
When the network returns, the app starts a background sync. The sync does not interrupt the user, does not lock the screen, and retries quietly when it fails. The only thing the user sees is the number of lines waiting to be sent.
The data model on the device
An embedded SQLite database on the device is sufficient and should be the choice. Rather than a shrunken copy of the server schema, a simple schema specific to counting is built.
The count session table holds the count's identity, branch, type, start time and status. The count line table writes one row per scan. The critical decision here: rows are written append-only, never updated. Scan the same barcode ten times and you do not update one row — you write ten rows.
That decision solves two problems at once. First, concurrent write conflicts disappear. Second, an audit trail forms naturally; which user scanned what in which second is visible. A wrong scan is not deleted — a correction row with the opposite sign is written.
Every row carries a unique identifier generated on the device. That identifier is not generated on the server, because the row has to have an identity before it reaches the server. A device identifier and the device's own incrementing counter are added alongside. The counter provides ordering; the unique identifier provides idempotency.
The device clock is not trustworthy. Users can change it and it can drift after a restart. So both the device time and the timestamp the server assigns during sync are stored, and reports use server time.
- CountSession — session id, branch, type, status
- CountLine — line id (uuid), device id, device counter, barcode, quantity, device time
- SyncQueue — list of unsent lines and their retry count
- Reference — local copy for barcode-to-product matching
Idempotent sync
The single most important property of the sync is that sending the same packet twice does not change the result. The network dropping, the response being lost and the request being retried are all ordinary events; in none of them should a line be written twice.
The mechanism that guarantees it is simple. The line identifier is generated on the client and the server has a unique index on it. A write does nothing if the identifier already exists. The server returns success in every case and puts the list of accepted identifiers in the response. The client removes those from the queue.
Packet size is settled by experiment. Packets of five hundred lines work well on a weak connection; larger packets time out and the whole packet is resent. One transaction is opened per packet, so no partially written packet is left behind.
The retry interval should grow exponentially and settle at an upper bound. When six terminals find the network at the same moment, all of them attacking with a full packet in the same second strains the server for no reason; adding a random spread prevents that pile-up.
The application layer stays thin at this point. The service on the Node.js side validates the lines, checks that the session is open, applies permissions and calls the bulk write procedure. The business logic is not there but in the stored procedure.
Count close and the variance procedure
Two conditions have to hold before a count can be closed: every terminal's queue must be empty and the session must be in a closeable state on the server. Closing happens not because someone pressed a button but because the system verified those two conditions.
At close, a stored procedure runs: it totals the scanned quantities by product, compares them with system stock and produces the variance rows. The critical detail is which moment's stock the comparison is made against. If sales and goods receipt continue while the count runs, a stock snapshot is taken at the moment the count opens and movements during the count are added separately.
Variance rows are not written straight to stock. They go to approval first. Variances above a certain value or a certain percentage require a second approval. Approved variances are written as count adjustment movements and their cost is posted through a defined line.
In a fourteen-thousand-line count, hundreds of variance rows typically appear. That list is useless to anyone in its raw form. Sorting variances by value, grouping repeated products and flagging likely causes is what makes it usable.
What to do
Test the counting app with no network, not with one. Put it in flight mode, finish the count, restart the device and only then turn the network on. If there is data loss, the architecture is incomplete.
Do not update rows, append them. Make corrections with an opposite entry. That one rule solves both the conflict problem and the audit trail requirement.
Generate the line identifier on the client and protect it with a unique index on the server. Without idempotency you cannot build a retry mechanism.
Treat server time as authoritative and keep device time only as an ordering hint.
The Mindpera field app runs on Capacitor and implements this contract: persistent local storage on the device, append-only line writes, idempotent sync and a variance list produced by a stored procedure at close. The Mind Stock module ties variances into an approval flow, and adjustment movements reach the cost side through the same procedure.