Documentation Index Fetch the complete documentation index at: https://docs.go-mizu.dev/llms.txt
Use this file to discover all available pages before exploring further.
In this tutorial, you’ll build a todo application that works offline and syncs automatically when reconnected. Offline-first apps are essential for mobile users and anyone with unreliable connections. Your app stays responsive even without internet, and changes from multiple users merge seamlessly when connectivity returns.
Step 1: Create the Project
mizu new synctodo --template sync
cd synctodo
go mod tidy
mizu dev
Open http://localhost:8080 to see the default syncing todo app.
Step 2: Understand the Default App
The template includes a working todo list. Let’s trace how data flows.
The Store
Look at service/todo/mutator.go:
type Store struct {
mu gosync . RWMutex
todos map [ string ] map [ string ] * Todo // scope -> id -> todo
}
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
The store uses a mutex for thread safety and organizes todos by scope (like a namespace).
The Apply Function
This is the heart of sync - it processes mutations:
func ( s * Store ) Apply ( ctx context . Context , m sync . Mutation ) ([] sync . Change , error ) {
switch m . Type {
case "todo.create" :
return s . createTodo ( scope , m . Args )
case "todo.toggle" :
return s . toggleTodo ( scope , m . Args )
case "todo.delete" :
return s . deleteTodo ( scope , m . Args )
}
return nil , fmt . Errorf ( "unknown mutation: %s " , m . Type )
}
Each mutation type maps to a specific operation.
The Snapshot Function
Returns current state for new clients:
func ( s * Store ) Snapshot ( ctx context . Context , scope string ) ( json . RawMessage , uint64 , error ) {
todos := s . GetAll ( scope )
data , _ := json . Marshal ( todos )
return data , 0 , nil
}
Step 3: Test Offline Behavior
Open the app in your browser
Add some todos
Open DevTools → Network → check “Offline”
Add more todos (they appear immediately!)
Uncheck “Offline”
Watch todos sync to server
The key insight: the UI updates before the server confirms.
Step 4: Add Priority Feature
Let’s add priority levels to todos.
Update the Model
Edit service/todo/mutator.go:
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
Priority string `json:"priority"` // "low", "medium", "high"
}
Update Create Function
func ( s * Store ) createTodo ( scope string , args json . RawMessage ) ([] sync . Change , error ) {
var input struct {
ID string `json:"id"`
Title string `json:"title"`
Priority string `json:"priority"`
}
if err := json . Unmarshal ( args , & input ); err != nil {
return nil , err
}
// Default priority
priority := input . Priority
if priority == "" {
priority = "medium"
}
todo := & Todo {
ID : input . ID ,
Title : input . Title ,
Completed : false ,
Priority : priority ,
}
s . todos [ scope ][ todo . ID ] = todo
data , _ := json . Marshal ( s . todos [ scope ])
return [] sync . Change {{ Scope : scope , Data : data }}, nil
}
Add Set Priority Mutation
func ( s * Store ) Apply ( ctx context . Context , m sync . Mutation ) ([] sync . Change , error ) {
s . mu . Lock ()
defer s . mu . Unlock ()
scope := m . Scope
if s . todos [ scope ] == nil {
s . todos [ scope ] = make ( map [ string ] * Todo )
}
switch m . Type {
case "todo.create" :
return s . createTodo ( scope , m . Args )
case "todo.toggle" :
return s . toggleTodo ( scope , m . Args )
case "todo.delete" :
return s . deleteTodo ( scope , m . Args )
case "todo.setPriority" :
return s . setPriority ( scope , m . Args )
default :
return nil , fmt . Errorf ( "unknown mutation: %s " , m . Type )
}
}
func ( s * Store ) setPriority ( scope string , args json . RawMessage ) ([] sync . Change , error ) {
var input struct {
ID string `json:"id"`
Priority string `json:"priority"`
}
if err := json . Unmarshal ( args , & input ); err != nil {
return nil , err
}
todo := s . todos [ scope ][ input . ID ]
if todo == nil {
return nil , fmt . Errorf ( "todo not found: %s " , input . ID )
}
todo . Priority = input . Priority
data , _ := json . Marshal ( s . todos [ scope ])
return [] sync . Change {{ Scope : scope , Data : data }}, nil
}
Update the JavaScript Client
Edit assets/js/sync.js to handle priority:
class SyncClient {
constructor ( url , scope ) {
this . url = url ;
this . scope = scope ;
this . pending = [];
this . state = [];
}
async createTodo ( title , priority = 'medium' ) {
await this . mutate ( 'todo.create' , {
id: crypto . randomUUID (),
title ,
priority ,
});
}
async setPriority ( id , priority ) {
await this . mutate ( 'todo.setPriority' , { id , priority });
}
async mutate ( type , args ) {
const mutation = {
id: crypto . randomUUID (),
type ,
args ,
scope: this . scope ,
};
// Optimistic update
this . applyLocal ( mutation );
this . render ();
// Queue and sync
this . pending . push ( mutation );
await this . sync ();
}
applyLocal ( mutation ) {
switch ( mutation . type ) {
case 'todo.create' :
this . state . push ({
id: mutation . args . id ,
title: mutation . args . title ,
completed: false ,
priority: mutation . args . priority || 'medium' ,
});
break ;
case 'todo.setPriority' :
const todo = this . state . find ( t => t . id === mutation . args . id );
if ( todo ) todo . priority = mutation . args . priority ;
break ;
}
}
render () {
const container = document . getElementById ( 'todos' );
container . innerHTML = this . state
. sort (( a , b ) => this . priorityOrder ( a ) - this . priorityOrder ( b ))
. map ( todo => `
<div class="todo priority- ${ todo . priority } ">
<span> ${ todo . title } </span>
<select onchange="sync.setPriority(' ${ todo . id } ', this.value)">
<option value="low" ${ todo . priority === 'low' ? 'selected' : '' } >Low</option>
<option value="medium" ${ todo . priority === 'medium' ? 'selected' : '' } >Medium</option>
<option value="high" ${ todo . priority === 'high' ? 'selected' : '' } >High</option>
</select>
</div>
` ). join ( '' );
}
priorityOrder ( todo ) {
return { high: 0 , medium: 1 , low: 2 }[ todo . priority ];
}
async sync () {
if ( this . pending . length === 0 ) return ;
try {
const res = await fetch ( this . url + '/_sync/push' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
scope: this . scope ,
mutations: this . pending ,
}),
});
if ( res . ok ) {
const { changes } = await res . json ();
if ( changes && changes . length > 0 ) {
this . state = JSON . parse ( changes [ 0 ]. data );
this . render ();
}
this . pending = [];
}
} catch ( e ) {
console . log ( 'Sync failed, will retry:' , e );
}
}
}
// Initialize
const sync = new SyncClient ( '' , 'default' );
Step 5: Add Conflict Resolution
When two users edit the same todo, we need to handle conflicts.
Last-Write-Wins Strategy
The simplest approach - later mutations win:
func ( s * Store ) setPriority ( scope string , args json . RawMessage ) ([] sync . Change , error ) {
var input struct {
ID string `json:"id"`
Priority string `json:"priority"`
Timestamp int64 `json:"timestamp"`
}
if err := json . Unmarshal ( args , & input ); err != nil {
return nil , err
}
todo := s . todos [ scope ][ input . ID ]
if todo == nil {
return nil , fmt . Errorf ( "todo not found: %s " , input . ID )
}
// Only apply if newer (last-write-wins)
if input . Timestamp > todo . UpdatedAt {
todo . Priority = input . Priority
todo . UpdatedAt = input . Timestamp
}
data , _ := json . Marshal ( s . todos [ scope ])
return [] sync . Change {{ Scope : scope , Data : data }}, nil
}
Update the Todo struct:
type Todo struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
Priority string `json:"priority"`
UpdatedAt int64 `json:"updatedAt"`
}
Step 6: Add Multi-User Support
Open the app in two browser windows to test collaboration.
Add User Tracking
// In app/server/app.go
func ( a * App ) setupSync () {
log := memory . NewLog ()
dedupe := memory . NewDedupe ()
a . syncEngine = sync . New ( sync . Options {
Log : log ,
Apply : a . store . Apply ,
Snapshot : a . store . Snapshot ,
Dedupe : dedupe ,
OnChange : func ( scope string , changes [] sync . Change ) {
// Broadcast to all connected clients via live server
for _ , change := range changes {
a . liveServer . Publish ( scope , "sync" , change . Data )
}
},
})
}
Client Listens for Updates
class SyncClient {
constructor ( url , scope ) {
this . url = url ;
this . scope = scope ;
this . pending = [];
this . state = [];
this . connectWebSocket ();
}
connectWebSocket () {
this . ws = new WebSocket ( `ws:// ${ location . host } /ws` );
this . ws . onmessage = ( e ) => {
const { topic , data } = JSON . parse ( e . data );
if ( topic === 'sync' ) {
this . state = JSON . parse ( data );
this . render ();
}
};
this . ws . onopen = () => {
// Subscribe to our scope
this . ws . send ( JSON . stringify ({
topic: 'subscribe' ,
data: this . scope ,
}));
};
this . ws . onclose = () => {
setTimeout (() => this . connectWebSocket (), 1000 );
};
}
}
How Sync Works
User A (offline) Server User B (online)
| | |
| --- Create todo -------- | (queued locally) |
| | |
| (goes online) | |
| | |
| --- Push mutations ----> | |
| | --- Apply -----------> |
| | --- Broadcast -------> |
| | | (UI updates)
| <-- Confirm ------------ | |
| | |
What You Learned
Mutation-based data changes
Optimistic UI updates
Offline queueing and sync
Conflict resolution strategies
Multi-user broadcasting
Next Steps
Sync Documentation Deep dive into sync internals
Live Template Add real-time features without full sync