How to build a real-time app with Crystal and Kemal

技术宅Kevin Novice 3h ago Updated Jul 26, 2026 230 views 8 likes 2 min read

Stop over-engineering your tech stack. Most developers reflexively reach for a heavy framework and a database for every project, but the framework should match the problem size, not your habits. I've been experimenting with this "right-sizing" philosophy while building a tool called Moving Motivators—a real-time session app where users rank cards privately until a facilitator triggers a reveal.

The core constraint here isn't just the real-time nature; it's server-side confidentiality. The server must physically refuse to send one participant's data to another until the reveal phase. This isn't a UI trick; it's a server invariant.

Why Kemal?

I chose Kemal because it's a Sinatra-style micro-framework. It provides routes, WebSockets, and request/response handling—and that's it. Since this app is ephemeral (state lives in memory and expires after a few hours), an ORM or a complex asset pipeline would just be dead weight.

My rule: the framework is just an adapter. All the business logic lives in plain Crystal objects that don't even import Kemal. This makes the domain logic easy to test without booting a server.

Domain Logic from Scratch

Instead of using generic arrays, I use value objects to enforce invariants. For example, a ranking must contain exactly ten unique cards. By validating this at construction, the rest of the app can trust the data implicitly.

struct Ranking
 getter order : Array(Motivator)

 def initialize(@order : Array(Motivator))
 validate!
 end

 # 1 is most important, 10 is least.
 def position(motivator : Motivator) : Int32
 @order.index!(motivator) + 1
 end

 private def validate!
 unless @order.size == Motivator.values.size
 raise ArgumentError.new("A ranking must order all ten cards")
 end

 if @order.uniq.size != @order.size
 raise ArgumentError.new("A ranking must not contain a duplicate card")
 end
 end
end

State Machine Control

The session is managed as a two-phase state machine: LobbyRevealed. Once a session is revealed, it's terminal. I use guard methods to ensure no rules are bypassed.

def reveal(token : String) : Nil
 raise Forbidden.new("Only the facilitator can reveal") unless token == @facilitator_token
 guard_lobby!
 @phase = Phase::Revealed
end

private def guard_lobby! : Nil
 raise WrongPhase.new("Session is already revealed") if revealed?
end

By passing the current time into the session logic from the caller, the expiry logic remains deterministic and trivial to test. This approach turns the server into a strict gatekeeper of data, ensuring privacy is maintained until the exact moment of reveal.

webdevAI ProgrammingAI CodingTutorialcrystal

All Replies (3)

K
KaiDev Expert 11h ago
Does this actually scale, or is it just fast until three people use it?
0 Reply
C
CameronCat Intermediate 11h ago
Don't forget to check out the Redis integration for handling the pub/sub part of real-time.
0 Reply
D
DeepSurfer Novice 11h ago
Switched to a leaner stack last year and my deployment time dropped to seconds. Love this.
0 Reply

Write a Reply

Markdown supported