Going fully serverless on Rhema Bible
How cost pushed Rhema from ASP.NET controllers to Azure Functions, how the 1M free monthly executions funded early growth, and why HTTP, queue, and timer triggers fit a mobile event-driven backend.
Rhema Bible started the way a lot of early products start. I needed an API for auth, notes, AI, TTS, and App Store webhooks. The instinct on .NET is an ASP.NET controllers project on a small always-on box. At the early stage that instinct is expensive.
Cost was the forcing function. App Store traffic is spiky. Most hours are quiet. Paying for an idle API just to host a handful of endpoints did not make sense. Azure Functions on the Consumption plan gives you a large free grant each month. For Rhema that was enough runway to ship, learn, and grow without a fixed compute bill.
So I went completely serverless. Controllers became Functions. Side effects became queue and timer triggers. The HTTP surface stayed familiar. The cost model and the runtime model changed.
Why always-on was the wrong default
A small VPS or App Service plan is simple. It is also always billing. Early Rhema needed OTP login, note sync, freemium AI quotas, TTS audio, and subscription webhooks. None of that justifies a warm server twenty four hours a day when daily active usage is still climbing.
Mobile traffic also refuses to be polite. Releases, sermons seasons, and feature launches create bursts. Idle capacity for bursts is waste. Serverless turns that waste into scale that only appears when a request or event arrives.
- Early-stage priority: ship features, not babysit servers.
- Spiky mobile traffic: pay for execution, not idle RAM.
- Azure Consumption free grant: roughly 1M executions a month before you feel real function spend.
The free grant as a product strategy
Azure Functions Consumption includes a monthly free grant. The number that mattered to me was the one million executions. For an early Bible app that is a lot of headroom if you design for it.
Designing for it means two things. Keep HTTP handlers thin. Push fan-out work onto queues and timers so one user action does not explode into dozens of synchronous paid calls inside the request path. Cache aggressively where money hurts, especially TTS.
That free grant is not infinite magic. Cold starts, GB-seconds, and outbound dependency cost still exist. It is still the difference between launching with a quiet bill and launching with a box you feel guilty shutting down.
From controllers to Functions without throwing away Clean Architecture
I did not rewrite the domain to chase serverless fashion. Rhema keeps Clean Architecture: Domain, Application, Infrastructure, and an Api.Functions host. Controllers were a delivery adapter. Functions are a different adapter over the same application services.
HTTP Functions still live under /api/v1 for auth, user, AI, notes, saved verses, TTS, and admin. The handlers validate input, enforce JWT or admin identity, call application services, and return results. Business rules stay out of the trigger files.
- Domain and Application stay testable without Azure.
- Functions project is composition root plus triggers.
- Isolated worker on .NET 8 keeps dependency injection familiar.
public class CreateNoteFunction
{
private readonly INoteService _notes;
public CreateNoteFunction(INoteService notes) => _notes = notes;
[Function("CreateNote")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/notes")]
HttpRequestData req,
FunctionContext context)
{
var userId = req.GetUserIdOrThrow();
var body = await req.ReadFromJsonAsync<CreateNoteRequest>();
var note = await _notes.CreateAsync(userId, body!, context.CancellationToken);
var res = req.CreateResponse(HttpStatusCode.Created);
await res.WriteAsJsonAsync(note);
return res;
}
}
Event-driven fits mobile products better than a fat request
The best part of going serverless on Rhema was not only cost. It was how naturally the product mapped onto triggers. A Bible app is full of work that should not block the phone.
User taps generate audio. HTTP Function checks object storage by content hash. Cache hit returns a SAS URL. Cache miss synthesizes, uploads the MP3, then returns the URL. Email and activity writes do not need to finish inside that HTTP call. They publish to the message bus. Separate Functions consume those queues with idempotent handlers.
Subscriptions arrive as webhooks. A Function updates premium state and queues lifecycle email. A Timer Function warms popular Bible TTS audio into object storage so the first listener on a busy passage is less likely to pay cold synthesis cost.
[Function("SynthesizeTts")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "v1/tts/synthesize")]
HttpRequestData req)
{
var userId = req.GetUserIdOrThrow();
var input = await req.ReadFromJsonAsync<TtsRequest>();
var hash = TtsHash.Compute(input!);
var existing = await _blobs.TryGetSasUrlAsync(hash);
if (existing is not null)
return await req.OkAsync(new TtsResponse(existing));
var mp3 = await _tts.SynthesizeAsync(input!);
await _blobs.UploadAsync(hash, mp3);
var sas = await _blobs.GetSasUrlAsync(hash);
await _bus.PublishAsync(new ActivityMessage(userId, "tts.synthesized", hash));
return await req.OkAsync(new TtsResponse(sas));
}
[Function("ProcessActivity")]
public async Task Run(
[ServiceBusTrigger("%ActivityQueue%", Connection = "ServiceBus")]
ActivityMessage message)
{
if (await _dedupe.AlreadyProcessedAsync(message.MessageId))
return;
await _activity.WriteAsync(message);
await _dedupe.MarkProcessedAsync(message.MessageId);
}
[Function("WarmPopularTts")]
public async Task Run([TimerTrigger("0 0 */6 * * *")] TimerInfo timer)
{
var passages = await _catalog.GetPopularPassageIdsAsync();
foreach (var passageId in passages)
{
var hash = await _tts.EnsureCachedAsync(passageId);
_logger.LogInformation("Warmed TTS {PassageId} => {Hash}", passageId, hash);
}
}
Trigger map for Rhema
Once you stop thinking in controllers only, the product becomes a set of reactions.
- HTTP trigger: auth, notes, AI, TTS, admin. JWT on app users. Admin identity on operators.
- Queue or Service Bus trigger: email send, activity write, anything that must retry.
- Timer trigger: TTS warmup, housekeeping, quota resets if you keep them server-side.
- Webhook HTTP trigger: IAP provider events that update premium and enqueue mail.
const rhemaTriggers = {
http: ["auth", "notes", "ai", "tts", "admin", "iap-webhook"],
queue: ["email", "activity"],
timer: ["tts-warmup"],
why: "mobile UX stays snappy; retries live with Azure",
} as const;
Staying inside the free grant without starving the product
One million executions sounds large until a chatty mobile client polls every few seconds. Rhema treats the free grant as a budget with architecture behind it.
TTS content addressing means repeat listens do not become repeat Function plus TTS provider spend. Prompt-hash caching on AI responses cuts duplicate model calls. IMemoryCache covers hot profile and existence checks inside a warm instance. Idempotent queue consumers stop webhook retries from double-writing.
Streaming AI over SSE still fits Functions. The client gets progressive output. Quota checks stay on the server. Freemium remains a product rule, not a hope.
- Cache before you scale out.
- Prefer events over synchronous fan-out in the HTTP path.
- Measure executions that do useful work versus retries of the same failure.
What I gave up, and what I refused to give up
Cold starts are real on Consumption. First request after idle can feel slower than a warm App Service. For Rhema that trade was acceptable. Notes and auth are short. TTS and AI are already dominated by provider latency. A timer warmup path helps popular audio.
Local debugging needs the Functions host and clear project boundaries. That is discipline, not a blocker. I refused to give up Clean Architecture, JWT discipline, idempotency, and structured application services just because the host changed.
I also refused a giant god Function. Many small triggers with clear names beat one mega handler that reimplements a whole MVC app.
How this compares to my other products
Tenlord keeps an API on a VPS for the steady HTTP path and still uses serverless Functions for mail, SMS, and payment apply. That hybrid is right when you have sustained authenticated traffic and want predictable latency on the main API.
Rhema at the early stage did not have that profile. Completely serverless was the honest fit. Same cloud family. Different product economics.
Would I do it again?
Yes. For an early mobile product with bursty use, paid third-party AI or TTS, and a need to reach production without burning cash on idle compute, Azure Functions plus queues plus timers is a strong default.
The free monthly execution grant bought time. Event-driven triggers bought clarity. Controllers-to-Functions was not a rewrite of the product brain. It was a cheaper, more honest host for the same application services.
If your early backend is mostly waiting for users who are not there yet, stop paying for the wait. Put the work on triggers. Spend the free million on real usage.