Sincronizarea offline-first e partea din dezvoltarea mobilă care pare trivială într-un demo și se transformă în mlaștină în momentul în care apar un al doilea telefon, o conexiune instabilă sau o înregistrare ștearsă. Am construit trei aplicații care o tratează diferit — PokiPaw, Rețetele Mele și FocusGuard — iar pattern-urile care au supraviețuit contactului cu realitatea sunt aceleași câteva, reutilizate cu mici variații. Articolul ăsta trece prin cele la care revin mereu: scrieri locale (local-first), o coadă de sincronizare, rezolvarea conflictelor prin last-write-wins, tombstones pentru ștergeri și delta sync versionat. Sunt sincer despre unde te mușcă fiecare, pentru că exact modurile de eșec sunt lecția adevărată.

Premisa de pornire e simplă. Un telefon nu e niciodată sigur online, iar un utilizator care apasă „salvează" și se uită la un spinner blocat în metrou își pierde rapid încrederea. Așa că scrierea trebuie să aterizeze mai întâi local și să ajungă la server mai târziu. Tot ce urmează e consecința acestei singure decizii.

Scrieri local-first: singura regulă fără excepții

Regula e aceeași în toate cele trei aplicații: UI-ul scrie sincron într-un store local, iar rețeaua e o grijă de fundal.

În Rețetele Mele, organizatorul meu de rețete, scrierile locale merg direct în Room (SQLite), iar UI-ul le reflectă instant. Un SyncManager propagă apoi schimbările către un server Django, după propriul lui ritm. În PokiPaw, aplicația de îngrijire a animalelor, o familie gestionează împreună un animal de pe mai multe telefoane, deci o scriere — „am notat masa de seară" — trebuie să pară imediată chiar dacă în final trebuie reconciliată cu editările tuturor celorlalți, pe Supabase. În FocusGuard, aplicația de screen-time, e invers: cea mai mare parte din starea sincronizabilă curge în jos dinspre Firestore (banca de mesaje, GIF-urile), deci local-first acolo înseamnă mai ales citire din cache și doar ocazional scriere.

Contrastul ăsta din urmă contează. „Offline-first" nu e o singură arhitectură. E un spectru, de la stare colaborativă cu multe scrieri (PokiPaw) la livrare de conținut cu multe citiri (FocusGuard), iar mecanismul de sincronizare de care ai nevoie diferă radical la cele două capete. Să alegi greșit — să construiești un rezolvator complet de conflicte bidirecțional pentru ceva care e de fapt un cache de conținut — e o cale clasică de a complica inutil.

Coada de sincronizare: scrieri durabile peste o conexiune căzută

Odată ce scrierile sunt locale, ai nevoie de ceva care să le ducă la server în mod fiabil, reîncercând când revine rețeaua. Pe Android mă bazez pe WorkManager exact pentru asta. Rețetele Mele programează munca de sincronizare în fundal prin el, deci o scriere făcută în metrou e trimisă când revine conectivitatea, supraviețuind faptului că aplicația a fost trimisă în fundal sau omorâtă. FocusGuard folosește același instrument pentru sincronizarea de conținut.

Proprietatea valoroasă a lui WorkManager (și a echivalentelor) e că sistemul de operare deține reîncercarea și backoff-ul. Îi dai o unitate de muncă cu constrângeri — „are nevoie de rețea" — și el persistă acea intenție peste moartea procesului. Capcana e să-l tratezi ca instant: nu e. Există latență între „utilizatorul a făcut acțiunea" și „serverul știe", iar UI-ul tău trebuie să fie comod să arate adevărul local în acel interval, fără să pretindă că a fost confirmat.

În cazul PokiPaw, coada e mai puțin explicită, pentru că Supabase Realtime împinge schimbări în ambele sensuri, iar logica de reconectare e mai degrabă despre a reda ce s-a întâmplat cât timp ai fost offline decât despre a goli un FIFO strict de scrieri în așteptare. Aceeași idee, altă formă: tamponezi local, reconciliezi la reconectare.

Rezolvarea conflictelor: last-write-wins și de ce e o armă încărcată

Când două telefoane editează aceeași înregistrare și ambele ajung în final la server, cineva trebuie să piardă. Cel mai ieftin răspuns — și cel folosit de Rețetele Mele — e last-write-wins (LWW): cea mai recentă scriere, după timestamp, le suprascrie pe celelalte. Pe server e un LWW server-wins; backend-ul e arbitrul.

LWW e perfect în multe cazuri. Un singur utilizator care își editează propria colecție de rețete între un telefon și (eventual) o tabletă aproape niciodată nu produce un conflict real, doar o copie veche care e corectată. Sunt câteva linii de logică în loc de o bibliotecă de CRDT, iar pentru un MVP ăsta e compromisul corect.

Dar LWW e o armă încărcată și ar trebui să știi încotro e îndreptată. Rezolvă conflictele aruncând date, în tăcere. Dacă doi oameni dintr-o familie PokiPaw editează același program de medicație de pe două telefoane — unul mărește doza, celălalt schimbă ora — LWW nu îmbină acele intenții; scrierea mai târzie câștigă pur și simplu, iar editarea anterioară dispare fără avertisment. De asta, în PokiPaw, tratez editarea concurentă cu mai mulți scriitori ca pe partea pe care cel mai mult vreau s-o întăresc, nu ca pe o problemă rezolvată. Poziția onestă e: LWW e corect până când doi oameni chiar nu sunt de acord asupra aceluiași câmp, și atunci devine greșit pe tăcute. Soluția nu e un algoritm mai sofisticat, cât expunerea conflictului — să-i arăți utilizatorului că două editări s-au ciocnit — lucru în care majoritatea aplicațiilor, inclusiv ale mele, investesc prea puțin.

Tombstones: gotcha-ul cu ștergerea care mușcă pe toată lumea

Iată bug-ul pe care fiecare dezvoltator de offline-first îl redescoperă personal. Ștergi o înregistrare pe dispozitivul A. Dispozitivul B n-a auzit niciodată de ștergere. La următoarea sincronizare, B are o copie pe care serverul n-o mai are, deci „o recreează" săritor — și lucrul șters învie din morți.

Ștergerile hard și sincronizarea nu se împacă. Un rând care pur și simplu a dispărut e, din punctul de vedere al unui peer care sincronizează, imposibil de distins de un rând care n-a ajuns niciodată. Răspunsul standard e tombstone-ul: nu ștergi rândul, îl marchezi ca șters (un flag de soft-delete plus un timestamp) și lași acel marcaj să se sincronizeze ca orice altă schimbare. Peer-ii văd „ăsta a fost șters la T" și își elimină copia locală în loc s-o reînvie.

Ăsta e gotcha-ul tombstone/soft-delete pe care l-am întâlnit la Rețetele Mele, și merită spus pe față pentru că e foarte ușor să livrezi varianta naivă fără să observi, până când un tester șterge o rețetă care refuză să rămână moartă. Tombstones vin cu propria coadă: se acumulează, deci în final ai nevoie de o politică de curățare (renunță la tombstones mai vechi decât cel mai lung interval offline plauzibil) și trebuie să te asiguri că fiecare cale de citire le filtrează, ca rândurile șterse să nu reapară în UI. Nimic din toate astea nu e greu. Dar tot e ușor de uitat.

Delta sync versionat: aduci doar ce s-a schimbat efectiv

Al treilea pattern e despre a nu redescărca toată lumea. FocusGuard livrează o bancă de mesaje motivaționale și de tip „busted" pentru ecranele overlay, plus reacții GIF, toate stocate în Firestore. Abordarea naivă readuce fiecare colecție la pornire. În schimb, FocusGuard face content caching cu version-diff: ține un marcaj de versiune per colecție, îl compară cu cel al serverului și readuce doar colecțiile care au derapat. Dacă mesajele nu s-au schimbat, dar GIF-urile da, coboară doar GIF-urile.

E oglinda, orientată pe citire, a cozii de scriere, și e un pattern la care aș apela ori de câte ori clientul ține în cache conținut deținut de server care se schimbă ocazional și imprevizibil. Câștigul e lățime de bandă și baterie; costul e că acum ai metadate de versionare de ținut oneste. Dacă marcajul de versiune minte — ai modificat datele dar ai uitat să incrementezi versiunea — clienții servesc pe tăcute conținut vechi, ceea ce e o clasă de bug-uri înnebunitoare, pentru că nimic nu dă eroare. Disciplina e să faci incrementarea versiunii inseparabilă de schimbarea datelor, ideal pe aceeași cale de scriere, ca cele două să nu poată deraia una de alta.

Cum alegi pattern-ul

Dacă comprim cicatricile de pe trei aplicații într-un sfat: scrie întâi local, mereu. Folosește planificatorul durabil de fundal al platformei (WorkManager sau ruda lui), ca scrierile să supraviețuiască morții procesului. Apelează la last-write-wins când conflictele sunt rare și datele au un singur proprietar — dar în ziua în care ai câmpuri editate de mai mulți scriitori, planifică să arăți conflictele, nu să le înghiți tăcut. Folosește tombstones din clipa în care ștergerile trebuie sincronizate și prevezi curățarea lor. Iar când clientul oglindește conținut deținut de server, versionează-ți colecțiile și adu doar delta.

Niciunul dintre astea nu e exotic. Priceperea nu stă în a cunoaște pattern-urile, ci în a le potrivi cu forma reală a datelor tale și a refuza să construiești varianta grea când cea ușoară se potrivește. Supra-ingineria sincronizării e modul în care mor proiectele solo — o lecție care străbate tot în a construi 11 produse ca fondator solo.

Dacă ăsta e genul de scris despre arhitectură aplicată pe care vrei să-l vezi mai des, trimit din când în când o notă când apare un articol nou — newsletter-ul e aici.

offline-firstmobilesyncFlutterRoomFirestore
Distribuie articolul