In our previous post, we explored how Horda entities work like Flutter's stateful widgets, but on the backend. We also mentioned that Horda is a stateful serverless platform. But what does that mean exactly?
If you are a Flutter developer, whenever someone mentions the word "serverless", there's a high chance that Firebase Cloud Functions would come to mind. Except unlike Horda, they are stateless.
So in this post let's explore what's the difference between stateful and stateless serverless, and how stateful serverless replaces traditional databases with a much faster, simpler, and more cost-efficient approach.
The Problem with Stateless Functions
When we say Firebase Cloud Functions are stateless, we mean they have complete amnesia. Every time a function is invoked, it starts from scratch with no memory of previous calls. It's like having a conversation where the other person forgets everything the moment you stop talking.
Let's look at a practical example to see how this plays out. Imagine you need to implement a bank account withdrawal: check if there's sufficient balance and update it atomically to prevent race conditions.
Here's how you'd implement this with Firebase Cloud Functions:
exports.withdrawFunc = onCall(async (request) => {
const { accountId, amount } = request.data;
const db = admin.firestore()
// Validate request data
if (!accountId || typeof accountId !== 'string') {
throw new HttpsError('invalid-argument', 'Invalid accountId');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new HttpsError('invalid-argument', 'Invalid amount');
}
const accountRef = db.collection('accounts').doc(accountId);
// Use transaction to handle race conditions
return await admin.firestore().runTransaction(async (transaction) => {
// Read account document from Firestore
const accountDoc = await transaction.get(accountRef);
// Check if account exists
if (!accountDoc.exists) {
throw new HttpsError('ACCOUNT_NOT_FOUND');
}
// Check if balance is sufficient
const currentBalance = accountDoc.data().balance;
if (currentBalance < amount) {
throw new HttpsError('INSUFFICIENT_FUNDS');
}
// Calculate new balance
const newBalance = currentBalance - amount;
// Write back to Firestore
transaction.set(accountRef, {
balance: newBalance,
});
return { newBalance: newBalance };
});
});
Now, here's the same operation with Horda:
class BankAccount extends Entity<BankAccountState> {
Future<RemoteEvent> withdraw(
WithdrawAccount command,
BankAccountState state,
EntityContext ctx,
) async {
if (state.balance < command.amount) {
throw AccountException('INSUFFICIENT_FUNDS');
}
return AccountBalanceUpdated(
newBalance: state.balance - command.amount,
);
}
}
@JsonSerializable()
class BankAccountState extends EntityState {
double balance = 0;
void balanceUpdated(AccountBalanceUpdated event) {
balance = event.newBalance;
}
}
Look at the difference! The Firebase version is 40+ lines, while Horda is just 20. But it's not just about line count. Let's see what's really happening here.
Breaking Down the Differences
Database Roundtrips vs State Ready to Use
Firebase Cloud Functions:
// Read account document from Firestore
const accountDoc = await transaction.get(accountRef);
// Check if account exists
if (!accountDoc.exists) {
throw new HttpsError('ACCOUNT_NOT_FOUND');
}
// Check if balance is sufficient
const currentBalance = accountDoc.data().balance;
Every single time your function runs, it has to fetch the account data from Firestore. The function remembers nothing. Even if the same account was accessed milliseconds ago, it starts from scratch with a database roundtrip. And each of these roundtrips costs you money, so you have to remember that Firestore charges per document read, write, and delete.
Horda:
Future<RemoteEvent> withdraw(
WithdrawAccount command,
BankAccountState state, // Current state is right here!
EntityContext ctx,
) async {
// You can easily access state to check the balance
if (state.balance < command.amount) {
throw AccountException('INSUFFICIENT_FUNDS');
}
return AccountBalanceUpdated(
// And calculate the new balance
newBalance: state.balance - command.amount,
);
}
The entity provides its state in the handler, so you can read state.balance immediately. No database calls. No roundtrips. No per-read charges. This is what we mean by stateful serverless: your code has access to state without manually having to fetch it from a database every time.
Database Orchestration Overhead vs Pure Business Logic
Firebase Cloud Functions:
const { accountId, amount } = request.data;
const db = admin.firestore()
// Validate request data
if (!accountId || typeof accountId !== 'string') {
throw new HttpsError('invalid-argument', 'Invalid accountId');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new HttpsError('invalid-argument', 'Invalid amount');
}
const accountRef = db.collection('accounts').doc(accountId);
return await admin.firestore().runTransaction(async (transaction) => {
const accountDoc = await transaction.get(accountRef);
// Check if account exists
if (!accountDoc.exists) { /* ... */ }
// Get balance from document
const currentBalance = accountDoc.data().balance;
// ...
transaction.set(accountRef, {
balance: newBalance,
});
return { newBalance: newBalance };
});
Look at how much code is dedicated to database operations: initializing Firestore, validating inputs, creating references, wrapping in transactions, reading, writing. Your actual business logic (checking balance and calculating the new amount) is buried somewhere in the middle. This is the 80/20 problem: 80% database orchestration, 20% actual logic.
Horda:
if (state.balance < command.amount) {
throw AccountException('INSUFFICIENT_FUNDS');
}
return AccountBalanceUpdated(
newBalance: state.balance - command.amount,
);
This is pure business logic. Check the balance. Calculate the new amount. Return what happened. No database code at all. Horda handles persistence behind the scenes: when your command handler returns an event, state is automatically updated and persisted to a state-of-the-art storage layer purpose-built for stateful serverless workloads. Horda also handles scaling and replication so your entities remain fast and available under any load.
Transaction Complexity vs Automatic Serialization
Firebase Cloud Functions:
// Use transaction to handle race conditions
return await admin.firestore().runTransaction(async (transaction) => {
// Read account document from Firestore
const accountDoc = await transaction.get(accountRef);
// ... validation and logic
// Write back to Firestore
transaction.set(accountRef, {
balance: newBalance,
});
return { newBalance: newBalance };
});
Without transactions, you'd have race conditions. Two simultaneous withdrawals could read the same balance, both think there's enough money, and both succeed, even if the account doesn't have enough for both. So you wrap everything in a Firestore transaction, which adds complexity and another layer of nesting.
Horda:
// Entity receives commands
WithdrawAccount command,
// And produces events
return AccountBalanceUpdated(
newBalance: state.balance - command.amount,
);
No transaction code needed. Horda entities work by executing business logic upon receiving commands and producing events which update the state. When multiple commands come in for the same entity, Horda automatically queues them and processes them one at a time, in order, with the correct state for each subsequent command. Your code runs with guaranteed consistency. Race conditions simply can't happen.
You also benefit cost-wise. A simple withdrawal operation requires at least one read (inside the transaction) and one write. If there's contention and the transaction retries, those costs multiply. High-traffic operations can quickly rack up significant database bills.
Learning Curve: JavaScript vs Dart
Firebase Cloud Functions:
exports.withdrawFunc = onCall(async (request) => {
const { accountId, amount } = request.data;
// Validate request data manually
if (!accountId || typeof accountId !== 'string') {
throw new HttpsError('invalid-argument', 'Invalid accountId');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new HttpsError('invalid-argument', 'Invalid amount');
}
// Other JavaScript code, Firestore API, HTTP errors...
});
As a Flutter developer, you're switching to JavaScript (or TypeScript), learning Firebase's API, understanding request/response patterns, and dealing with a completely different programming model. You also have to manually validate all incoming data because JavaScript doesn't have strong typing by default.
Horda:
// Command handler - just a Dart function, with well defined signature
Future<RemoteEvent> withdraw(
WithdrawAccount command,
BankAccountState state,
EntityContext ctx,
) async { /* ... */ }
// Your command, event, and state - strongly typed Dart classes with serialization
@JsonSerializable()
class WithdrawAccount extends RemoteCommand { /* ... */ }
@JsonSerializable()
class AccountBalanceUpdated extends RemoteEvent { /* ... */ }
@JsonSerializable()
class BankAccountState extends EntityState { /* ... */ }
You're writing Dart, the same language you use for Flutter. Your command handler is just a regular Dart function. Your commands, events, and state are strongly typed classes with @JsonSerializable annotations, which means:
-
No manual validation needed - Dart's type system ensures
amountis a double, not a string or null -
Automatic serialization - The
@JsonSerializableannotation generates all the serialization code for you - Familiar patterns - As described in the previous post, everything feels natural because it's the same language and patterns you already know
Try It Out!
Ready to experience stateful serverless for yourself? The Horda SDK includes a local development server, so you can start building on your own machine before deploying to production.
Check out these resources to get started:
- Horda Website - Learn more about the stateful serverless platform
- Quick Start - Build your first real-time counter app
- Example Projects - Explore Counter and bank account examples
We'd love to hear your thoughts and feedback as you explore!
Top comments (0)