A web application that cannot handle asynchronous operations is not a web application. Database queries take time. Network requests take time. User interactions are inherently asynchronous. If the VM blocks on every I/O operation, the entire application freezes.
Session 033 addressed this by adding async state management and WebSocket support to FLIN's runtime. The result was a VM that could handle real-time bidirectional communication, track the lifecycle of asynchronous operations, and update the UI reactively when async results arrived.
This article covers the async execution model, the WebSocket implementation, and how they integrate with FLIN's reactivity system.
---
The Async Execution Model
FLIN does not have green threads, coroutines, or an async/await syntax at the language level. Instead, async behaviour is built into the runtime layer. The VM executes FLIN bytecode synchronously, and the Rust runtime handles asynchronous I/O underneath.
This design was deliberate. FLIN targets application developers, not systems programmers. The developer should not need to reason about thread safety, data races, or deadlocks. They should write save user and the runtime handles the rest -- whether that save is synchronous (in-memory) or asynchronous (network call to a remote database).
The bridge between synchronous bytecode and asynchronous I/O is the AsyncState enum:
pub enum AsyncState {
Idle,
Loading,
Success(Value),
Error(String),
}Every asynchronous operation in FLIN goes through this lifecycle:
1. Idle -- The operation has not started. 2. Loading -- The operation is in progress. 3. Success -- The operation completed with a result. 4. Error -- The operation failed with an error message.
The VM tracks async state per variable. When a FLIN program initiates an async operation (a database query, a network fetch, a file read), the runtime sets the variable's state to Loading, starts the operation on a background Tokio task, and continues executing bytecode. When the operation completes, the runtime updates the state to Success or Error and triggers a reactivity update.
---
The JavaScript Runtime API
On the client side, async operations are exposed through the FLIN runtime API:
// Start an async operation
_flinAsync('users', fetch('/api/users').then(r => r.json()));// Check loading state if (_flinIsLoading('users')) { showSpinner(); }
// Get error state const error = _flinGetError('users'); if (error) { showError(error); } ```
The _flinAsync function takes a variable name and a Promise. It immediately sets the variable's state to Loading and triggers a UI update (showing a loading indicator). When the Promise resolves, it sets the state to Success with the result and triggers another update (replacing the spinner with data). If the Promise rejects, it sets the state to Error and triggers an update (showing an error message).
This three-state model eliminates an entire class of UI bugs where the developer forgets to handle the loading or error case. In FLIN views, you write:
view {
{if loading(users)}
<p>Loading...</p>
{else if error(users)}
<p>Error: {error_message(users)}</p>
{else}
{for user in users}
<p>{user.name}</p>
{/for}
{/if}
}The view always shows something appropriate: a loading indicator, an error message, or the data. There is no state where the view shows stale data from a previous request or flickers between states.
---
Debounce and Throttle
Two utility functions prevent excessive async operations:
// Debounce: wait until user stops typing
_flinDebounce('search', function() {
_flinAsync('results', fetch('/api/search?q=' + query));
}, 300);// Throttle: at most once per interval _flinThrottle('scroll', function() { _flinAsync('items', fetch('/api/items?page=' + page)); }, 1000); ```
_flinDebounce delays execution until the specified milliseconds have passed without another call. This is essential for search-as-you-type: you do not want to fire a network request on every keystroke.
_flinThrottle ensures the function is called at most once per interval. This is useful for scroll handlers and resize listeners that would otherwise fire hundreds of times per second.
Both functions are implemented in the FLIN runtime (under 20 lines each) and are available to all event handlers.
---
WebSocket Support
Session 033 added full WebSocket support to FLIN's server runtime. WebSockets provide bidirectional, full-duplex communication over a single TCP connection -- the protocol for real-time features like live chat, notifications, collaborative editing, and real-time dashboards.
Server-Side Implementation
The WebSocket server is implemented in src/server/websocket.rs (450+ lines). It handles the full RFC 6455 protocol:
pub struct WebSocketHandler {
connections: HashMap<ConnectionId, WebSocketConnection>,
subscriptions: HashMap<String, HashSet<ConnectionId>>,
entity_watchers: HashMap<String, HashSet<ConnectionId>>,
}impl WebSocketHandler {
pub async fn handle_upgrade(
&mut self,
request: Request,
) -> Result
// Spawn connection handler let conn_id = ConnectionId::new(); let connection = WebSocketConnection::new(ws_stream); self.connections.insert(conn_id, connection);
// Start message loop tokio::spawn(async move { self.handle_messages(conn_id).await; });
Ok(response) } } ```
The WebSocket endpoint lives at /_ws. When a browser connects, the server performs the HTTP upgrade handshake, creates a new WebSocketConnection, and spawns a Tokio task to handle incoming messages.
Entity Change Subscriptions
The most powerful feature of FLIN's WebSocket integration is entity change subscriptions. A client can subscribe to changes on a specific entity type and receive real-time notifications when entities are created, updated, or deleted:
// Client side
_flinWsConnect();
_flinWsSubscribe('Todo');
_flinOnEntityChange('Todo', function(change) {
// change.type: 'create' | 'update' | 'delete'
// change.entity: the affected entity
refreshTodoList();
});On the server side, when a save or delete operation modifies an entity, the runtime checks the subscription map and pushes a change event to all subscribed connections:
pub fn notify_entity_change(
&mut self,
entity_type: &str,
change_type: ChangeType,
entity: &EntityInstance,
) {
if let Some(subscribers) = self.entity_watchers.get(entity_type) {
let message = serde_json::json!({
"type": "entity_change",
"entity_type": entity_type,
"change": change_type.as_str(),
"entity": entity.to_json(),
});for conn_id in subscribers { if let Some(conn) = self.connections.get(conn_id) { let _ = conn.send(Message::Text(message.to_string())); } } } } ```
This means that when one user saves a new Todo item, every other user who has subscribed to Todo changes sees the new item appear in real time. No polling. No manual refresh. The database change flows through the WebSocket to the browser to the DOM automatically.
---
Integrating Async with Reactivity
The async system and the reactivity system are deeply connected. When an async operation completes, it does not just update a variable -- it triggers the reactivity system to re-evaluate all bindings that depend on that variable.
The flow is:
1. User action triggers an async operation (e.g., clicking a "Load" button).
2. The runtime sets the variable to Loading state.
3. The reactivity system re-renders bindings: loading indicators appear.
4. The async operation completes (success or error).
5. The runtime updates the variable to Success(data) or Error(msg).
6. The reactivity system re-renders bindings: data or error message appears.
Each state transition triggers exactly one reactivity update. The requestAnimationFrame batching ensures that multiple simultaneous state changes (e.g., two API calls completing at the same time) result in a single DOM update.
---
WebSocket Auto-Reconnect
Network connections are unreliable. Mobile devices switch between Wi-Fi and cellular. Laptops go to sleep. Servers restart. The WebSocket client must handle disconnections gracefully.
function _flinWsConnect() {
const ws = new WebSocket('ws://' + location.host + '/_ws');ws.onopen = function() { console.log('[FLIN WS] Connected'); // Re-subscribe to all previous subscriptions for (const type of _wsSubscriptions) { ws.send(JSON.stringify({ action: 'subscribe', type: type })); } };
ws.onclose = function() { console.log('[FLIN WS] Disconnected, reconnecting in 1s...'); setTimeout(_flinWsConnect, 1000); };
ws.onmessage = function(event) { const data = JSON.parse(event.data); if (data.type === 'entity_change') { const handlers = _wsHandlers[data.entity_type] || []; handlers.forEach(fn => fn(data)); } };
_ws = ws; } ```
On disconnection, the client waits one second and reconnects. On reconnection, it re-subscribes to all entity types it was previously watching. This means that a brief network interruption is invisible to the user -- the WebSocket reconnects and subscriptions resume automatically.
The one-second delay prevents rapid reconnection attempts when the server is down. In production, an exponential backoff (1s, 2s, 4s, 8s, up to 30s) would be more appropriate. For the dev server, a fixed one-second delay is sufficient.
---
Concurrency in the Rust Runtime
Under the hood, FLIN's server uses Tokio for async I/O. The HTTP server, WebSocket handler, SSE broadcaster, and file watcher all run on the Tokio runtime as asynchronous tasks.
The VM itself is synchronous -- it executes bytecode in a single thread. But the Rust code surrounding the VM (the server, the database engine, the file I/O layer) is fully asynchronous. This hybrid model gives FLIN the best of both worlds:
- Simple bytecode execution: No concurrency concerns in the VM. No locks, no atomics, no data races. The VM is a pure state machine.
- Efficient I/O: The server handles hundreds of concurrent connections without blocking. WebSocket messages are sent and received asynchronously. Database operations can run in background tasks.
The connection between synchronous VM execution and asynchronous I/O is the Tokio broadcast channel (for SSE) and the WebSocket handler's message loop (for real-time updates). These channels are the seams where synchronous and asynchronous code meet.
---
Error Handling in Async Operations
Async operations introduce failure modes that synchronous code does not have. A network request can time out. A WebSocket connection can drop. A database query can fail. The async system must handle all of these gracefully.
FLIN's approach is to surface errors through the AsyncState::Error variant. When an async operation fails, the error message is captured and made available to the view:
_flinAsync('users', fetch('/api/users')
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
);If the fetch fails (network error, timeout, HTTP error status), the Promise rejects, and the runtime sets the variable's state to Error with the error message. The view can then display the error:
{if error(users)}
<div class="error">
<p>Failed to load users: {error_message(users)}</p>
<button click={retry_load_users()}>Retry</button>
</div>
{/if}This pattern -- try, fail, display error, offer retry -- is baked into the runtime rather than left to the developer to implement manually. Every async operation gets error handling for free.
For WebSocket connections, errors trigger the auto-reconnect mechanism. A disconnection is not an error that the application sees -- it is a transient condition that the runtime handles. The application only sees the data flowing through the connection, not the connection lifecycle.
---
Test Results
Session 033 added 23 new tests, bringing the total to 700:
- WebSocket upgrade handshake tests
- Subscription and unsubscription tests
- Entity change notification tests
- Async state lifecycle tests
- Reconnection behaviour tests
The session also completed Phase 11 (View Rendering) at 100% -- all 30 tasks done. The overall project stood at 63%.
---
What This Unlocked
With async operations and WebSocket support, FLIN gained the ability to build real-time applications. A collaborative todo list. A live dashboard. A chat application. All in a single .flin file, with the runtime handling async state, WebSocket connections, and UI updates automatically.
The developer writes save todo and {for todo in Todo.all}. The runtime saves the entity, notifies all connected browsers via WebSocket, and the view updates in every browser tab simultaneously. Zero infrastructure configuration. Zero WebSocket boilerplate. Zero manual state synchronisation.
This is the architectural dividend of building async and concurrency into the runtime rather than bolting it on as a library. The developer does not need to learn a WebSocket library, configure a message broker, or write event handlers for connection lifecycle. The runtime does it all.
Consider what the same feature would require in a typical JavaScript stack: a WebSocket server (Socket.IO or ws), a pub-sub mechanism (Redis or a custom event emitter), client-side connection management (reconnect logic, subscription state), and manual wiring between the database layer and the WebSocket layer. That is four libraries, three configuration files, and hundreds of lines of boilerplate. In FLIN, it is a built-in capability of the runtime that works out of the box.
---
This is Part 27 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language from scratch.
Next up: [28] The Reactivity Engine -- how FLIN makes everything reactive with automatic dependency tracking and incremental DOM updates.