peerreynders
GenServer docs: "handle_cast ... should be used sparingly" Why?
During my recent query with regard to “Functional Web Development with Elixir, OTP, and Phoenix”, Lance Halvorson kindly directed my attention toward the GenServer Documentation:
handle_call/3must be used for synchronous requests. This should be the default choice as waiting for the server reply is a useful backpressure mechanism.handle_cast/2must be used for asynchronous requests, when you don’t care about a reply. A cast does not even guarantee the server has received the message and, for this reason, should be used sparingly.
Can somebody please direct me towards some material that may explain how we arrived at these particular recommendations?
My current puzzlement is based on the observation that many modern architectures rely less and less on “synchronous” operations and are moving more into a pipelined model where {event,current_state} enters the processing pipeline and new_state pops out of the other end - and perhaps more importantly that the provider of event isn’t necessarily designed to be the consumer of new_state. As simple examples I would point to React’s Flux, The Elm Architecture, and perhaps to some extent ReactiveX (when done correctly).
In essence I would have thought that waiting an for an “unnecessary response” adds an unnecessary interaction dependency that unnecessarily reduces concurrency - realizing this would ultimately lead to to a design style where one would strive to make responses unnecessary wherever possible - at which point cast/2 / handle_cast/2 would become the defacto default interaction (rather than call/3 / handle_call/3).
“Designing for Scalability with Erlang/OTP; Chapter 4: Generic Servers - Message Passing - Asynchronous Message Passing” p.84
In some applications, client functions return a hardcoded value, often the atom ok, relying on side effects executed in the callback module. Such functions could be implemented as asynchronous calls.
On the topic of backpressure DSEO talks about backpressure/load regulation frameworks like jobs and Safetyvalve. There really isn’t an indication that trying to manage backpressure at the granularity of a single message exchange is a good idea.
“Designing for Scalability with Erlang/OTP; Chapter 15: Scaling Out - Load Regulation and Backpressure” p.421
Start controlling load only if you have to. When deploying a website for your local flower shop, what is the risk of everyone in town flocking to buy flowers simultaneously? If, however, you are deploying a game back end that has to scale to millions of users, load regulation and backpressure are a must.
That being said forcing call/3 based requests can be a legitimate tactic to prevent individual client processes from overwhelming a server with requests as described in Building Non Blocking Erlang Apps. Essentially a GenServer isn’t actually obliged to immediately return a reply in handle_call/3 but can choose to answer the request later with reply/2 - i.e. the server can keep the client blocked while not being blocked itself.
Most Liked
sasajuric
I believe that the second sentence of the second point you quoted provides the most important reason:
A cast does not even guarantee the server has received the message and, for this reason, should be used sparingly.
I usually advise that if you’re unsure which one you need, a call is likely a better default. Cast can hurt you in some strange ways. A message can become lost (and it’s completely unclear that it happened), or if the message queue builds up, the performance of the entire system might suffer, or you might run out of memory.
Call is more explicit here, because a client gets a feedback, meaning you can find out whether a server has processed a message, or crashed in the process. Moreover, a call bottleneck is less likely to affect the entire system (though it’s still possible of course).
Both are definitely valid options, and there are definitely cases where cast is a better choice. IIRC, I’ve even occasionally used 2-way casts in place of a call, but I’d have to search my memory for the exact reasons why I did it.
net
You can reply early from a call if you just want a receipt of arrival.
def handle_call(:foo, from, state) do
GenServer.reply(from, :ok)
do_work()
{:noreply, state}
end
peerreynders
Interesting approach.
def handle_call(:foo, from, state) do
GenServer.reply(from, :ok) # !!! I almost missed this part !!!
new_state = do_work()
{:noreply, new_state}
end
To me this further illustrates that one needs to be very clear on what the reply really “means” and what the reply is meant to accomplish.
- In the simplest and most common case one is simply waiting for a result that one needs to continue - though that opens up other questions that could be worth consideration:
A) Should I be waiting for a result or should I be re-organizing my logic to work in a GenStage kind-of-way?
B) Do I really want to be blocked for the result? - Waiting for an :ok reply can mean
a) I just want to make sure you got the message - past that if something goes wrong it’s entirely your (and your supervisors) problem.
b) I really need to know that my request was processed to completion - if it didn’t, I need to “let my supervisor know” (i.e. I need to fail).
Building Non Blocking Erlang Apps is a nice tactic for a server to remain responsive even when dealing with slow services/resources. But what if a client of such a server doesn’t want to be blocked either?
This is where I thought the “call-with-acknowledge/cast-result-back-to-client” interaction pattern might come in handy. But that would mean having to rework the server’s interface. It probably would be simpler and better for the client to spin-off a proxy process (Task.start[_link]?) for the sole purpose of handling one request with that server - it doesn’t matter if the proxy process is blocked - because that’s it’s job. The client process can go off and do it’s thing, until the proxy reports back with a result or error (and terminates).
A one-off proxy process may also be better when dealing with timeouts because stale messages wouldn’t have a mailbox to go to, so the “real” client isn’t burdened with clearing out stale messages.
The point being process interaction isn’t simply limited to cast and call but can be customized with process links, monitors and other (short lived) processes; so it would be a big mistake to mentally equate a call to a process to a function invocation when in fact process interactions can be handled in many and more nuanced ways.
Yes - I know it looks like I’m overthinking (over-engineering) this but
In preparing for battle I have always found that plans are useless, but planning is indispensable. Dwight D. Eisenhower
i.e. it’s important to know the full spectrum of your available options - or in my case before choosing the simplest possible approach I want to assess all available options first so that I can legitimately select the simplest option as the appropriate and best option - rather than just selecting something “by default”.
peerreynders
I suspected as much - but recommendations like this, at least in my experience, can easily turn into “dogma” (i.e. “handle_cast/2 is bad”) all too easily wielded by the expert-beginners out there.
While novices want hard-and-fast rules, I think they need to face the fact that the call vs cast debate is simply an “it depends” issue. My current thinking is along the lines of:
- Can I get away with just a
cast? - If I think I need a
call- can I reorganize the interaction patterns so that I can just use acast? i.e. forward the “result” of thecastto an independent (concurrent, third) process to act on it? - Use a
callif the previous two options result in “unreasonable” concessions.
all the while remaining aware of any bottlenecks in the system where tons of messages could be piling up in a mailbox somewhere. I feel that this thinking is quite far removed from “handle_call/3 should be the default choice”.
In the end a successful developer needs to consciously realize that the code inside the process is utterly sequential (which makes it easy to reason about) while all the concurrency is happening between processes.
call can prevent a single process from dumping a million messages in your mailbox - it cannot prevent a million processes from putting a single message each in your mailbox.
Reminds me of CSP - certainly seems like the way to go once it has been established that backpressure is necessary.
Thank You for your feedback!
peerreynders
Somehow that description failed to evoke the picture of the destination server being entirely absent. I think I got distracted by the backpressure justification in handle_call.
![]()
Having had some time to think about I can see a definite use for an interaction pattern like
callclient request with an acknowledgement reply from server- Followup
castfrom server to client with the actual “result”
Thank You for chiming in!







