Offline-fähige App für Außendienst: Daten erfassen ohne Netz
Eine Außendienst-App arbeitet ohne Netz zuverlässig, wenn sie jede Eingabe zuerst auf dem Gerät speichert und Änderungen in einer Warteschlange sammelt, die sie bei Empfang abarbeitet. Für die Offline-App im Außendienst braucht es dazu eine feste Regel für den Fall, dass zwei Geräte denselben Datensatz ändern.
Für die imm INNOVATION GmbH & Co. KG haben wir beeSmart entwickelt, eine App zum Ablesen von Messgeräten. Sie nutzt die Kamera des Geräts, liest per NFC-Scanner und arbeitet auch ohne Verbindung, umgesetzt mit Ionic, Angular, Capacitor und Tailwind CSS in drei Monaten Projektlaufzeit. Warum NFC dort eine Web-App ausschloss und eine hybride App nötig machte, steht im Vergleich PWA oder native App.
Für Entscheider in Kürze: Eine Außendienst-App speichert jede Eingabe zuerst auf dem Gerät, sammelt Änderungen in einer Warteschlange und gleicht sie ab, sobald Netz da ist. Ändern zwei Personen dasselbe Feld unterschiedlich, entscheidet ein Mensch, statt dass eine Eingabe stillschweigend verschwindet. Planen Sie dafür eine lokale Datenbank auf dem Gerät, eine feste Konfliktregel und Tests auf den Geräten Ihrer Mitarbeiter ein.
Die fünf Schritte unten beschreiben ein Architekturmuster für diese Anforderungen: erfassen ohne Netz, später abgleichen, Konflikte erkennen. Es ist kein Abbild des beeSmart-Codes. Der Kern ist ein Codeausschnitt für die Warteschlange, den wir unter Node.js mit zwei simulierten Geräten, einem simulierten Server und Funklöchern getestet haben. Wer nur die Entscheidung vorbereitet, kann die Codeblöcke überspringen.
Schritt 1 · Grundannahme
Warum Offline der Normalfall ist
Eine Außendienst-App sollte so gebaut sein, als gäbe es kein Netz, und die Verbindung als Gelegenheit zum Abgleich nutzen. In Kellern, Technikräumen, Tiefgaragen und auf dem Land ist Empfang die Ausnahme, und ein schwaches Signal ist für eine App schwerer zu behandeln als gar keines: Anfragen hängen, bis sie nach Sekunden abbrechen.
Daraus folgen drei Festlegungen für die Oberfläche:
Jede Eingabe wird sofort auf dem Gerät gespeichert. Der Speichern-Knopf wartet nie auf den Server.
Die App zeigt an, wie viele Änderungen noch auf den Abgleich warten, etwa „3 Ablesungen nicht übertragen“.
Ein Abgleich startet bei jeder Gelegenheit: beim Öffnen der App, wenn das Netz zurückkommt, und auf Knopfdruck.
Das Network-Plugin von Capacitor meldet über das Ereignis networkStatusChange, ob eine Verbindung besteht. Ob der Server erreichbar ist, beantwortet erst eine Anfrage. Der Code in Schritt 3 behandelt deshalb jeden gescheiterten Versand wie ein Funkloch.
Eine solche App ist eine Single Page App mit Logik und Zustand auf dem Gerät. Für Websites ist das selten nötig, für Offline-Erfassung ist es Voraussetzung, weil kein Server die nächste Seite liefern kann.
Schritt 2 · Ablage
Lokale Speicherung auf dem Gerät
Eine hybride App mit Capacitor hat mehrere Orte für Daten, und nur einer davon taugt für erfasste Werte, die noch nicht beim Server sind. Die Capacitor-Dokumentation (Version 8, aktuelle Hauptversion am 11.08.2026) rät, localStorage in der WebView als flüchtig zu behandeln, und nennt für IndexedDB auf iOS dasselbe Risiko: Das Betriebssystem kann diesen Speicher räumen, wenn auf dem Gerät Platz fehlt. Für größere oder abfragbare Datenmengen nennt sie SQLite als die am breitesten unterstützte Lösung.
Auf unserer Seite zur App-Entwicklung nennen wir für Offline-First drei Bausteine: lokalen Speicher, Konfliktauflösung beim Abgleich und verschlüsselte Ablage sensibler Daten. Die Tabelle zeigt, welcher Speicher den ersten und dritten Baustein trägt. Auf Android entfernt eine Deinstallation laut Android-Dokumentation App-Dateien, Einstellungen und lokale Datenbank. Was nicht übertragen ist, geht dabei verloren; die Zähleranzeige aus Schritt 1 hilft, ein Gerät nicht mit offener Warteschlange zurückzusetzen.
| Geeignet für | Grenze | |
|---|---|---|
| Capacitor Preferences | Einstellungen, Anmeldestatus, kleine Schlüssel-Wert-Paare | laut Doku keine lokale Datenbank, keine Abfragen; nutzt UserDefaults auf iOS und SharedPreferences auf Android |
| localStorage und IndexedDB der WebView | Zwischenspeicher für Daten, die der Server jederzeit neu liefern kann | kann bei Platzmangel vom Betriebssystem geräumt werden; WebKit löscht unter Speicherdruck die Daten ganzer Origins nach letzter Nutzung |
| SQLite über das Community-Plugin @capacitor-community/sqlite (8.1.1 vom 06.08.2026, für Capacitor 8) | Erfasste Werte, Warteschlange, Abfragen über viele Datensätze | Community-Plugin, Updates selbst verfolgen; Verschlüsselung mit SQLCipher nur in den nativen Apps |
| Dateien über das Filesystem-Plugin, Verzeichnis Data | Fotos der Kamera, Pfad steht in SQLite | Verzeichnis Cache meiden, das System darf es bei Platzmangel leeren |
Schritt 3 · Abgleich
Synchronisieren und Konflikte lösen
Die App speichert jede Änderung in zwei Formen, in derselben Transaktion: den neuen Stand des Datensatzes und einen Eintrag in einer Warteschlange, der Outbox. Jeder Eintrag bekommt auf dem Gerät eine UUID, die Version des Datensatzes, die das Gerät kannte, und je geändertem Feld den alten und den neuen Wert. Der Abgleich schickt die Einträge einzeln und in Reihenfolge. Scheitert einer, bricht er ab und versucht es später ab genau diesem Eintrag.
Neue Ablesungen erzeugen keine Konflikte, sie werden nur angehängt. Eine Ablesung mit einer UUID vom Gerät kann der Server ein zweites Mal empfangen und erkennt sie wieder. Konflikte entstehen bei Stammdaten, die mehrere Personen pflegen, etwa der Hinweis, wo ein Zähler hängt, oder sein Status.
Für diese Fälle gilt im Code genau eine Regel:
Kennt das Gerät die aktuelle Version des Datensatzes, übernimmt der Server alle Felder der Änderung.
Ist die Version älter, prüft der Server jedes Feld einzeln. Steht dort noch der alte Wert, den das Gerät beim Bearbeiten gesehen hat, übernimmt er den neuen. Steht dort schon derselbe neue Wert, ist nichts zu tun.
Hat jemand das Feld inzwischen anders geändert, behält der Server seinen Wert. Das Gerät legt den Fall mit beiden Werten in eine Konfliktliste, und ein Mensch entscheidet. Wählt er den eigenen Wert, entsteht daraus eine neue Änderung auf dem aktuellen Stand.
Diese Regel verwirft keine Eingabe ohne Rückfrage, anders als „die letzte Änderung gewinnt“. Die Antwort des Servers enthält den Stand des Datensatzes nach dieser Änderung, damit das Gerät auch Änderungen anderer Personen sieht. Noch nicht gesendete eigene Änderungen legt der Code darüber.
// outbox.ts: Warteschlange für Änderungen, die auf dem Gerät entstehen.
// Konfliktregel (Server): Kennt das Gerät die aktuelle Version, gilt die
// Änderung. Sonst wird je Feld verglichen: Steht auf dem Server noch der
// Ausgangswert des Geräts (oder schon der neue Wert), wird übernommen.
// Hat jemand das Feld anders geändert, bleibt der Serverwert, und das
// Gerät legt den Fall in die Konfliktliste, damit ein Mensch entscheidet.
export type Value = string | null;
export type Fields = Record<string, Value>;
export type LocalRecord = { id: string; version: number; fields: Fields };
export type Change = {
changeId: string; // UUID vom Gerät: macht wiederholtes Senden harmlos
recordId: string;
baseVersion: number; // Serverversion, die das Gerät beim Bearbeiten kannte
fields: Record<string, { from: Value; to: Value }>;
};
export type Conflict = { recordId: string; field: string; mine: Value; server: Value };
export type SyncResult = {
status: 'applied' | 'conflict';
version: number;
record: Fields; // Serverstand nach dieser Änderung
conflicts: Record<string, { mine: Value; server: Value }>;
};
// Ablage auf dem Gerät, z. B. SQLite: jede Methode läuft als eine Transaktion.
export interface Store {
getRecord(id: string): Promise<LocalRecord>;
saveEdit(record: LocalRecord, change: Change): Promise<void>;
outbox(): Promise<Change[]>; // älteste Änderung zuerst
settle(changeId: string, record: LocalRecord, conflicts: Conflict[]): Promise<void>;
}
export async function edit(store: Store, id: string, patch: Fields): Promise<void> {
const rec = await store.getRecord(id);
const fields: Change['fields'] = {};
for (const [name, to] of Object.entries(patch)) {
const from = rec.fields[name] ?? null;
if (from !== to) fields[name] = { from, to };
}
if (Object.keys(fields).length === 0) return;
const change: Change = {
changeId: crypto.randomUUID(), recordId: id, baseVersion: rec.version, fields,
};
// Datensatz und Outbox-Eintrag gemeinsam speichern, nie getrennt
await store.saveEdit({ ...rec, fields: { ...rec.fields, ...patch } }, change);
}
export async function flush(
store: Store, send: (c: Change) => Promise<SyncResult>,
): Promise<number> {
let done = 0;
for (const change of await store.outbox()) {
let result: SyncResult;
try {
result = await send(change);
} catch {
break; // kein Netz oder Serverfehler: Reihenfolge bleibt, später erneut
}
// Serverstand übernehmen, noch nicht gesendete eigene Änderungen obenauf
const fields: Fields = { ...result.record };
for (const p of await store.outbox()) {
if (p.recordId !== change.recordId || p.changeId === change.changeId) continue;
for (const [name, f] of Object.entries(p.fields)) fields[name] = f.to;
}
const conflicts = Object.entries(result.conflicts)
.map(([field, v]) => ({ recordId: change.recordId, field, ...v }));
await store.settle(change.changeId,
{ id: change.recordId, version: result.version, fields }, conflicts);
done++;
}
return done;
}
// Versand an Laravel. apiBase ist die volle Adresse des Servers, etwa
// 'https://api.example.de': Eine relative Adresse wie '/api/...' zeigt in
// einer Capacitor-App auf den lokalen WebView-Server, nicht auf Laravel.
// getToken liefert das Sanctum-Token des angemeldeten Geräts.
// Der Timeout bricht hängende Anfragen im schwachen Netz ab; flush()
// behandelt das wie ein Funkloch und versucht es später erneut.
export function httpSender(
apiBase: string, getToken: () => Promise<string>, timeoutMs = 15000,
): (c: Change) => Promise<SyncResult> {
return async (c) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${apiBase}/api/sync/changes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json', Accept: 'application/json',
Authorization: `Bearer ${await getToken()}`,
},
body: JSON.stringify({
change_id: c.changeId, record_id: c.recordId,
base_version: c.baseVersion, fields: c.fields,
}),
signal: controller.signal,
});
if (res.status === 200 || res.status === 409) return await res.json();
throw new Error(`Sync abgebrochen: HTTP ${res.status}`); // z. B. 401: neu anmelden
} finally {
clearTimeout(timer);
}
};
}
// Verwendung: await flush(store, httpSender('https://api.example.de', loadToken));
Schritt 4 · Server
Der Laravel-Endpunkt prüft Version und Felder
Auf dem Server setzt ein Endpunkt dieselbe Regel um. Zusätzlich sperrt er den Datensatz während der Prüfung, damit zwei Geräte nicht gleichzeitig gegen denselben Stand prüfen; dafür dient lockForUpdate des Query Builders innerhalb einer Datenbanktransaktion, das Laravel seit vielen Versionen kennt. Außerdem merkt er sich jede verarbeitete UUID samt Antwort. Geht die Antwort im Funkloch verloren, schickt das Gerät denselben Eintrag noch einmal und bekommt dieselbe Antwort, ohne dass die Änderung doppelt wirkt. Das nennt man Idempotenz. Im Außendienst ist der Fall wahrscheinlich, weil Verbindungen mitten in einer Anfrage abreißen. Die wiederholte Antwort enthält den Stand von damals; hat inzwischen jemand anders den Datensatz geändert, zeigt das Gerät bis zum nächsten Abruf des Datensatzes kurz einen älteren Stand. Holen Sie deshalb nach jedem Abgleich die geänderten Datensätze einmal frisch vom Server.
Konflikte beantwortet der Endpunkt mit dem Status 409, übernommene Änderungen mit 200. Jeder andere Status, etwa 401 bei abgelaufener Anmeldung oder 422 bei ungültigen Daten, lässt den Abgleich im Codeausschnitt anhalten. Weil die App von einer anderen Adresse als der Server aus anfragt (capacitor://localhost auf iOS, https://localhost auf Android), muss die CORS-Konfiguration von Laravel diese Herkunft für den Pfad api/* zulassen; die Datei config/cors.php legt php artisan config:publish cors an. In einer echten App gehört ein dauerhaft abgelehnter Eintrag in eine eigene Fehlerliste, damit er die Warteschlange nicht blockiert.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
// routes/api.php: Route::post('/sync/changes', SyncChangeController::class)->middleware('auth:sanctum');
class SyncChangeController
{
private const FIELDS = ['location_note', 'access_hint', 'status'];
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'change_id' => ['required', 'uuid'],
'record_id' => ['required', 'uuid'],
'base_version' => ['required', 'integer', 'min:0'],
'fields' => ['required', 'array:'.implode(',', self::FIELDS)],
'fields.*.from' => ['present', 'nullable', 'string'],
'fields.*.to' => ['present', 'nullable', 'string'],
]);
$result = DB::transaction(function () use ($data) {
// Zeile sperren, bis die Transaktion endet: keine zwei Abgleiche gleichzeitig
$row = (array) DB::table('meter_points')
->where('id', $data['record_id'])
->lockForUpdate()
->firstOrFail();
// Schon verarbeitet (Antwort ging verloren)? Dann dieselbe Antwort noch einmal
$previous = DB::table('sync_changes')->where('change_id', $data['change_id'])->value('result');
if ($previous !== null) {
return json_decode($previous, true);
}
$merge = self::merge($row, (int) $data['base_version'], $data['fields']);
if ($merge['changed'] !== []) {
DB::table('meter_points')->where('id', $row['id'])
->update($merge['changed'] + ['version' => $merge['version']]);
}
$result = [
'status' => $merge['status'],
'version' => $merge['version'],
'record' => array_intersect_key($merge['changed'] + $row, array_flip(self::FIELDS)),
'conflicts' => (object) $merge['conflicts'],
];
DB::table('sync_changes')->insert([
'change_id' => $data['change_id'],
'result' => json_encode($result),
'created_at' => now(),
]);
return $result;
});
return response()->json($result, $result['status'] === 'conflict' ? 409 : 200);
}
// Konfliktregel wie im Beitrag beschrieben; Felder sind Textspalten
public static function merge(array $row, int $baseVersion, array $fields): array
{
$updates = [];
$conflicts = [];
foreach ($fields as $name => $change) {
$server = $row[$name];
if ($baseVersion === (int) $row['version'] || $server === $change['from']) {
$updates[$name] = $change['to'];
} elseif ($server !== $change['to']) {
$conflicts[$name] = ['mine' => $change['to'], 'server' => $server];
}
}
$changed = array_filter($updates, fn ($value, $name) => $row[$name] !== $value, ARRAY_FILTER_USE_BOTH);
return [
'status' => $conflicts === [] ? 'applied' : 'conflict',
'version' => $changed === [] ? (int) $row['version'] : (int) $row['version'] + 1,
'changed' => $changed,
'conflicts' => $conflicts,
];
}
}
Schritt 5 · Werkzeug
Hybrid-App mit Ionic und Capacitor
Ionic liefert die Oberfläche, Capacitor die Brücke zu Kamera, NFC, Dateisystem und SQLite. Am 11.08.2026 sind Capacitor 8 und Ionic Framework 8 die aktuellen Hauptversionen (Ionic 8.8.17 vom 05.08.2026); Capacitor 8.0.0 erschien am 08.12.2025, die jüngste Version vor diesem Beitrag ist 8.5.0 vom 31.07.2026. Die Logik der Warteschlange hängt an keiner dieser Versionen. Sie liegt in reinem TypeScript, und nur die Store-Klasse spricht mit dem SQLite-Plugin. Das hält den Teil testbar, an dem die Datenqualität hängt, und erleichtert spätere Upgrades.
Für einen Abgleich bei geschlossener App gibt es das Plugin Background Runner. Seine Dokumentation nennt die Grenzen: Auf iOS entscheidet das System, wann und wie oft eine Aufgabe läuft, mit etwa 30 Sekunden Laufzeit je Aufruf; auf Android liegen wiederkehrende Aufgaben mindestens 15 Minuten auseinander. Planen Sie den Abgleich deshalb vor allem für die Momente, in denen die App offen ist.
Testen unter echten Bedingungen
Für den Codeausschnitt oben haben wir ein Testprogramm geschrieben, das zwei Geräte und einen Server simuliert und neun Fälle prüft: verschiedene Felder offline geändert, dasselbe Feld unterschiedlich geändert, dasselbe Feld gleich geändert, verlorene Antwort mit erneutem Senden, zwei Änderungen am selben Feld in Folge, Netzabbruch mitten im Abgleich, veraltete lokale Kopie, Konflikt mit noch ausstehender eigener Änderung, verlorene Antwort mit späterer Änderung durch das zweite Gerät. Der letzte Fall ist der aufschlussreichste: Der Server hat die Änderung von Gerät A übernommen, A weiß es nicht. Inzwischen ändert Gerät B dasselbe Feld. Sendet A erneut, erkennt der Server die UUID und antwortet wie beim ersten Mal. Ohne diese Erinnerung hätte er die Wiederholung als neue Änderung auf altem Stand geprüft und A einen Konflikt gemeldet, den es nie gab.
Auf echten Geräten kommen die Fälle dazu, die keine Simulation abbildet: Flugmodus während des Sendens, App beenden mit voller Warteschlange, Gerät neu starten, wenig freier Speicher. Prüfen Sie jeden dieser Fälle auf einem Gerät des Typs, den Ihre Mitarbeiter im Einsatz haben.
Nachgefragt
Häufige Fragen
-
Betreffen die Änderungen verschiedene Felder, übernimmt der Server beide. Ändern beide Geräte dasselbe Feld auf unterschiedliche Werte, gilt der Wert, der zuerst ankam, und das zweite Gerät zeigt der Person, die es bedient, beide Werte zur Entscheidung an. So geht keine Eingabe stillschweigend verloren.
-
Sie ist geeignet, solange die erfassten Daten in einer nativen Ablage wie SQLite liegen und nicht im Speicher der WebView, den das Betriebssystem räumen kann. Gerätefunktionen wie Kamera und NFC erreicht die App über Capacitor-Plugins. Grenzen hat sie beim Abgleich bei geschlossener App, weil das Betriebssystem bestimmt, wann Hintergrundaufgaben laufen.
Ihre Mitarbeiter erfassen dort, wo kein Netz ist?
Beschreiben Sie uns, was vor Ort erfasst wird und wie viele Geräte dieselben Daten bearbeiten. Wir skizzieren Ablage, Warteschlange und Konfliktregel für Ihren Fall.
Nachweise
Quellen und Stand
Stand: 11.08.2026. Die Warteschlange (outbox.ts) wurde unter Node.js mit TypeScript und einem Testprogramm geprüft, das zwei Geräte und einen Server simuliert; httpSender wurde dort mit nachgebildetem fetch auf Basisadresse, Token, Timeout und Fehlerstatus geprüft. Der PHP-Teil wurde auf Syntax und die Methode merge mit eigenen Testfällen geprüft, der Endpunkt nicht gegen eine Datenbank ausgeführt.
Artikel teilen