Why Verity?¶
The Landscape¶
The Core Problem
How do you keep frontend state synchronized with the server without creating a mess?
Every team faces this challenge. Most reach for one of these solutions:
Examples: htmx, LiveView, Turbo Streams, Server Components
Approach: Server pushes HTML/DOM updates
Examples: TanStack Query, RTK Query, Apollo, SWR
Approach: Client manages cache with manual invalidation
Examples: Fetch + useState, EventSource + Redux
Approach: Custom solution for each project
Approach: Server-authored directives + deterministic cache
Philosophy: Backend of the frontend
Detailed Comparisons¶
vs Server-Rendered (htmx, LiveView, Turbo)¶
Strong Points
- ✅ Server is the source of truth
- ✅ No client-side cache complexity
- ✅ Simple mental model
- ✅ Works without JavaScript
Pain Points
- ❌ Server dictates DOM structure
- ❌ Tight coupling between backend and view
- ❌ Hard to support multiple clients (web + mobile)
- ❌ View-state mixed with truth-state
- ❌ Limited framework choice (locked to server's templating)
Scenario: You want to add a mobile app
htmx/LiveView:
1. Server returns HTML for web
2. Mobile app can't use it
3. Build separate REST API for mobile
4. Now maintaining two backends
5. Synchronization headaches
Verity:
Data Intent, Not DOM
Server sends:
Not:
Benefits:
- Server doesn't know about React vs Alpine vs Vue
- Same backend for web, mobile, desktop
- View-state stays client-owned
- Framework flexibility
vs Client Caches (TanStack Query, Apollo, RTK Query)¶
Strong Points
- ✅ Mature ecosystems
- ✅ Good developer tools
- ✅ Multi-framework support
- ✅ Flexible query APIs
- ✅ Active communities
Conceptual Gaps
1. Optimistic Updates Encouraged
// TanStack Query pattern
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Optimistically update cache
queryClient.setQueryData(['todos'], old => [...old, newTodo])
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos)
}
})
Problem: Lying to users, rollback flicker, complexity
2. App-Defined Invalidation
// Manual invalidation (glue code)
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
// Developer decides what to invalidate
queryClient.invalidateQueries(['todos'])
queryClient.invalidateQueries(['todo', todoId])
queryClient.invalidateQueries(['stats'])
// Did I miss anything? 🤷
}
})
Problem: Every developer reinvents invalidation logic
3. No Level Conversion Planning
// Fetch full detail
const todo = useQuery(['todo', id, 'full'], fetchFullTodo)
// Later fetch summary
const summary = useQuery(['todo', id, 'summary'], fetchSummaryTodo)
// Redundant fetch! Full data already has summary fields
Problem: No way to derive one level from another
4. No Truth-State Boundary
// Query cache used for everything
const [page, setPage] = useState(1) // View-state... or?
const todos = useQuery(['todos', page]) // Truth-state
const [sortBy, setSortBy] = useState('name') // View-state?
Problem: No explicit separation between truth and view
Scenario: Update a todo's status
TanStack Query:
// Option 1: Optimistic update
queryClient.setQueryData(['todo', id], { ...old, completed: true })
// → User sees change immediately
// → If server rejects, rollback (flicker)
// → Other tabs don't know about change
// Option 2: Wait for response
await mutate({ id, completed: true })
// → No optimistic update, feels slow
await queryClient.invalidateQueries(['todos'])
// → Refetch entire list (wasteful)
// Option 3: Manual cache update
queryClient.setQueryData(['todos'], old =>
old.map(t => t.id === id ? { ...t, completed: true } : t)
)
// → Now maintaining cache logic in app code
Verity:
const res = await fetch(`/api/todos/${id}`, {
method: 'PUT',
body: JSON.stringify({ completed: true })
})
const { directives } = await res.json()
// Server returned:
// [
// { op: "refresh_item", name: "todo", id: 42 },
// { op: "refresh_collection", name: "todos" }
// ]
await registry.applyDirectives(directives)
// → Server decides what changed
// → Verity refetches only what's needed
// → SSE broadcasts to other tabs automatically
// → No optimistic updates, no rollbacks
Server-Authored Contract
1. No Optimistic Updates
Show honest loading states. Let server confirm truth.
2. Server Decides Invalidation
@app.put('/api/todos/<int:id>')
def update_todo(id):
todo.status = request.json['status']
db.session.commit()
# Server knows what changed
return {
'directives': [
{ 'op': 'refresh_item', 'name': 'todo', 'id': id },
{ 'op': 'refresh_collection', 'name': 'todos' }
]
}
No guessing. Server has domain knowledge.
3. Level Conversion Planning
registry.registerType('todo', {
levels: {
full: {
fetch: ({ id }) => fetch(`/api/todos/${id}?full`),
checkIfExists: (obj) => 'comments' in obj,
levelConversionMap: {
summary: true // full → summary without refetch
}
}
}
})
One fetch satisfies multiple levels.
4. Explicit Truth-State Boundary
<div x-data="{
todos: $verity.collection('todos'), <!-- Truth -->
sortBy: 'name', <!-- View -->
page: 1 <!-- View -->
}">
Clear separation enforced by API.
vs Roll-Your-Own¶
Valid Reasons
- "Our needs are unique"
- "We need full control"
- "Don't want dependencies"
- "Just a few API calls"
The Hidden Complexity
Phase 1: Weeks 1-2
// Simple start
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
fetch('/api/data').then(r => r.json()).then(setData)
.finally(() => setLoading(false))
}, [])
Phase 2: Weeks 3-4
// Need caching
const cache = new Map()
function useCachedData(url) {
if (cache.has(url)) return cache.get(url)
// ... fetch and cache ...
}
Phase 3: Month 2
// Need invalidation
function invalidateCache(pattern) {
for (const key of cache.keys()) {
if (key.match(pattern)) cache.delete(key)
}
}
// Now every mutation needs this
await updateUser(id, data)
invalidateCache(/users/)
invalidateCache(/user-${id}/)
// Did I get them all? 🤷
Phase 4: Month 3
// Need to handle race conditions
let activeQueryId = 0
function fetchData(url) {
const queryId = ++activeQueryId
const res = await fetch(url)
if (queryId !== activeQueryId) return // Stale
// ...
}
Phase 5: Month 4
// Need real-time updates
const eventSource = new EventSource('/api/events')
eventSource.onmessage = (event) => {
const { type, data } = JSON.parse(event.data)
// How do I know what to invalidate?
// How do I avoid refetching everything?
// How do I handle disconnects?
// 🤷🤷🤷
}
Phase 6: Month 6
Hidden Costs
Engineering Time:
- Initial implementation: 2-4 weeks
- Debugging race conditions: ongoing
- Adding features teams expect: months
- Onboarding new developers: hours each
Opportunity Cost:
- Not building features
- Not fixing bugs
- Not improving UX
Maintenance:
- Every developer learns your custom system
- No community support
- No shared knowledge
- Technical debt accumulates
What You Get
Immediate:
- ✅ Caching with staleness tracking
- ✅ Race condition handling
- ✅ Deduplication
- ✅ SSE integration
- ✅ Memory management
- ✅ Multi-client sync
Long-term:
- ✅ Community knowledge
- ✅ Shared patterns
- ✅ Documentation
- ✅ Examples
- ✅ Battle-tested code
- ✅ Framework flexibility
Feature Comparison Matrix¶
| Feature | htmx/LiveView | TanStack Query | Apollo | Roll-Your-Own | Verity |
|---|---|---|---|---|---|
| Server as Source of Truth | ✅ | ⚠️ Optional | ⚠️ Optional | ❌ Up to you | ✅ |
| No Optimistic Updates | ✅ | ❌ Encouraged | ❌ Built-in | ❌ Tempting | ✅ |
| Framework Agnostic | ❌ | ✅ | ⚠️ Limited | ✅ | ✅ |
| Server-Authored Invalidation | N/A | ❌ | ❌ | ❌ | ✅ |
| Level Conversion Planning | N/A | ❌ | ❌ | ❌ | ✅ |
| Multi-Client Sync | ⚠️ Via polling | ❌ Manual | ⚠️ Subscriptions | ❌ | ✅ |
| Race Condition Handling | N/A | ⚠️ Manual | ⚠️ Manual | ❌ | ✅ |
| Truth/View Separation | ❌ | ❌ | ❌ | ❌ | ✅ |
| Maintenance Burden | Low | Medium | Medium | High | Low |
| Learning Curve | Low | Medium | High | High | Medium |
When NOT to Use Verity¶
- Server-rendered UI is your priority
- You're building a single-client web app
- You want minimal JavaScript
- Your team is backend-heavy
- SEO is critical
You'll be happy if: You don't need mobile apps or complex client-side state
- You're comfortable with optimistic updates
- Your team knows it well already
- You don't need multi-client sync
- Invalidation complexity is acceptable
You'll be happy if: Your app is mostly read-heavy with simple mutations
- You're already all-in on GraphQL
- Your team has GraphQL expertise
- Subscription complexity is acceptable
You'll be happy if: GraphQL's benefits outweigh its complexity for your use case
- Your needs are truly unique
- You have dedicated data layer engineers
- You're building framework-level infrastructure
You'll be happy if: You have resources to maintain custom solutions long-term
When to Use Verity¶
Verity Shines When
- Web app + mobile app
- Multiple web interfaces
- Desktop + web
- Shared data across clients
- Multi-user dashboards
- Operational tools
- Admin interfaces
- Any tool where staleness causes problems
- Lots of related entities
- Different detail levels needed
- Frequent mutations
- Need deterministic caching
- Financial applications
- Healthcare systems
- Compliance dashboards
- Anywhere flicker erodes trust
Migration Paths¶
Ready to switch? We have detailed migration guides:
-
Step-by-step guide to migrating from TanStack Query (React Query)
-
How to move from Apollo to Verity while keeping GraphQL
-
Transitioning from server-rendered to data-driven approach
Summary¶
Verity's Philosophy
Truth-state comes from the server. View-state stays in the view. Directives keep them synchronized. Never lie to users.
Choose Verity if you value:
- ✅ Correctness over perceived speed
- ✅ Explicit contracts over implicit behavior
- ✅ Server authority over client speculation
- ✅ Framework flexibility over vendor lock-in
- ✅ Multi-client support over single-platform optimization
The result: Calm, trustworthy interfaces that reflect reality.