ruby

Stream Updates to Your Users with LiteCable for Ruby on Rails

Julian Rubisch

Julian Rubisch on

Stream Updates to Your Users with LiteCable for Ruby on Rails

So far in this series, we have been exploring the capabilities of SQLite for classic HTTP request/response type usage. In this post, we will push the boundary further by also using SQLite as a Pub/Sub adapter for ActionCable, i.e., WebSockets.

This is no small feat: WebSocket adapters need to handle thousands of concurrent connections performantly. The emergence of alternatives to ActionCable — read AnyCable — bears witness to the fact that this is a pressing concern for modern web applications. We'll take a look at how SQLite performs under these conditions.

But first, let's set up our app to broadcast streaming updates to users via Turbo Streams.

Configure Your Ruby on Rails App to Use LiteCable for Websockets

Configuring your application to use LiteCable for WebSocket connections is as easy as specifying the adapter in config/cable.yml:

yaml
development: adapter: litecable test: adapter: test staging: adapter: litecable production: adapter: litecable

Preparing Our Rails App for Live Updates

Before we dive deep into how to broadcast model updates using Turbo Rails, we need to rework the mechanics of creating a prediction.

First, we'll create an empty prediction in GenerateImageJob to display a placeholder in our _prompt partial. This has the added benefit of forwarding the actual prediction's SGID to the webhook. Note, though, that we also have to pass the account's SGID, because the incoming webhook doesn't have any session information about the currently active user.

diff
# app/jobs/generate_image_job.rb class GenerateImageJob < ApplicationJob include Rails.application.routes.url_helpers queue_as :default def perform(prompt:) + empty_prediction = prompt.predictions.create model = Replicate.client.retrieve_model("stability-ai/stable-diffusion-img2img") version = model.latest_version version.predict({prompt: prompt.title, image: prompt.data_url}, replicate_rails_url(host: Rails.application.config.action_mailer.default_url_options[:host], - params: {sgid: prompt.to_sgid.to_s})) + params: {prediction: empty_prediction.to_sgid.to_s, + account: prompt.account.to_sgid.to_s})) end end

In parallel, in our ReplicateWebhook, we can locate and simply update the prediction. Note that we have to set the Current.account because Prompt is scoped to an account and would otherwise end up empty (due to the way AccountScoped is set up).

diff
# config/initializers/replicate.rb class ReplicateWebhook def call(prediction) query = URI(prediction.webhook).query - sgid = CGI.parse(query)["sgid"].first + prediction_sgid = CGI.parse(query)["prediction"].first + account_sgid = CGI.parse(query)["account"].first - prompt = GlobalID::Locator.locate_signed(sgid) + located_prediction = GlobalID::Locator.locate_signed(sgid) + Current.account = GlobalID::Locator.locate_signed(account_sgid) - prompt.predictions.create( + located_prediction.update( prediction_image: URI.parse(prediction.output.first).open.read, replicate_id: prediction.id, replicate_version: prediction.version, logs: prediction.logs ) end end

This change entails that the created prediction is empty, i.e., has no prediction image (obviously). Let's cater for this by adding a conditional to our _prompt.html.erb partial. When the image is missing, we display a spinner:

diff
<!-- app/views/prompts/_prompt.html.erb --> <p> <strong>Generated images:</strong> <% prompt.predictions.each do |prediction| %> + <% if prediction.prediction_image.present? %> <%= image_tag prediction.data_url %> + <% else %> + <sl-spinner style="font-size: 8rem;"></sl-spinner> + <% end %> <% end %> </p>

Great, we're done preparing our app to deliver live updates. Let's implement Turbo-Rails model broadcasts to finish this proof of concept.

Delivering Prediction Updates Live with Turbo-Rails

To test the WebSocket capabilities of LiteStack, we are going to use Turbo::Broadcastable. We'd like to show the spinner and the generated image once it has been created.

The way to do that is quite idiomatic: We tie this to after_create_commit and after_update_commit model callbacks invoking one of Turbo::Broadcastable's broadcast methods. Before we can do that, though, let's separate out a model partial for Prediction:

erb
<!-- app/views/predictions/_prediction.html.erb --> <%= turbo_stream_from prediction %> <div id="<%= dom_id(prediction) %>"> <% if prediction.prediction_image.present? %> <%= image_tag prediction.data_url %> <% else %> <sl-spinner style="font-size: 8rem;"></sl-spinner> <% end %> </div>

Observe that I added a turbo_stream_from tag to the partial, containing the stream identifier and subscribing to the channel. We can now simply call render from the prompt partial and add another turbo_stream_from to listen for changes to the prediction list:

diff
<!-- app/views/prompts/_prompt.html.erb --> <p> <strong>Generated images:</strong> - <% prompt.predictions.each do |prediction| %> - <% if prediction.prediction_image.present? %> - <%= image_tag prediction.data_url %> - <% else %> - <sl-spinner style="font-size: 8rem;"></sl-spinner> - <% end %> - <% end %> + <%= turbo_stream_from :predictions %> + <div id="<%= dom_id(prompt, :predictions) %>"> + <%= render prompt.predictions %> + </div> </p>

Now we're ready to set up model broadcasts. In the Prediction class, we add two model callbacks, invoking two Turbo Stream actions.

diff
# app/models/prediction.rb class Prediction < ApplicationRecord + include ActionView::RecordIdentifier + after_create_commit -> { broadcast_append_later_to :predictions, + target: dom_id(prompt, :predictions) } + after_update_commit -> { broadcast_replace_later_to self } belongs_to :prompt def data_url encoded_data = Base64.strict_encode64(prediction_image) "data:image/png;base64,#{encoded_data}" end end

What's Happening Here?

First, when a prediction is created, we append it to the predictions list. This will show our loading spinner once GenerateImageJob has run.

Then, every update to the record will trigger a replace of the prediction partial. Once the prediction is updated in ReplicateWebhook, the image returned from Replicate displays.

Here's what this looks like (note that I'm using Shoelace components for styling purposes):

Benchmarks: LiteCable Vs. Redis

So far, this article has shown that it's possible to run ActionCable with LiteCable as its adapter. This is a nice proof of concept, but we're here to check how LiteStack compares to other adapters as well.

Luckily, the official LiteStack benchmarks include measurements for LiteCable against Redis, which I am going to quote here.

Here's a small but important caveat: All these measurements were performed on the same machine. In typical production setups with managed Redis, you'll have to factor in additional network latency.

Let's look at the requests per second metric first. This captures how many Pub/Sub requests the Redis and SQLite processes are able to serve.

RequestsRedis requests/secondLiteStack requests/second
1,00026113058
10,00031105328
100,00034035385

Note that LiteStack is able to process more requests per second, but tapers off with higher loads. Though not representative, this might be an issue for loads of 1M requests and beyond — but that's when you'll typically reach for faster solutions like AnyCable over stock ActionCable anyway.

Furthermore, there are some latency tests included in the benchmarks.

RequestsRedis p90 LatencyLiteStack p90 LatencyRedis p99 LatencyLiteStack p99 Latency
1,000342715378
10,0008140138122
100,0004136153235

Allowing for some inaccuracy of measurement, both perform equivalently in this regard, maybe with the exception of the 99 percentile. Here, SQLite's locking model interferes with the amount of concurrent requests.

Again keep in mind, though, that you'll have to add a couple of milliseconds of latency once Redis runs on a different machine (LiteCable always runs on the same machine by design).

Limitations of SQLite for Rails

It is fair to assume that once you hit a certain level of Pub/Sub activity, you'll reach the ceiling of what's possible with a single SQLite database. That's the moment when you'll have to think about sharding, and here other technologies like Redis have a head start — though it will be interesting to see what LiteFS will have to offer.

Continuous monitoring of your app's WebSocket performance metrics using tools like AppSignal is your friend here. Reusing the ActionCable consumer on the client side is also advisable, as it will prevent wasting Pub/Sub connections.

LiteCable is tailored for vertical scaling by a tight integration of components. If you extract maximum performance from the SQLite engine, the limits of this approach are pushed a lot further. Once you observe that your latencies start to explode, though, I would suggest researching options like AnyCable, which inherently provide better strategies for horizontal scaling.

Up Next: Speed Up Rails App Rendering with LiteCache

In this post, we explored using SQLite as a Pub/Sub adapter for ActionCable to enable real-time updates in a Rails application via WebSockets. Configuring LiteCable was straightforward, requiring just a simple adapter specification. Leveraging Turbo::Broadcastable model callbacks made our implementation clean, tying broadcasts to creation and updates.

Though powerful, LiteCable is not designed to scale across multiple processes or servers. But for single-machine deployments, it unlocks real-time features in Rails without requiring a separate Redis instance.

Our next post will look at the next puzzle piece in LiteStack: the ActiveSupport cache store it provides. We'll test out how it can help us to lower server response times, and look at some benchmarks again.

See you then!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Julian Rubisch

Julian Rubisch

Our guest author Julian is a freelance Ruby on Rails consultant based in Vienna, specializing in Reactive Rails. Part of the StimulusReflex core team, he has been at the forefront of developing cutting-edge HTML-over-the-wire technology since 2020.

All articles by Julian Rubisch

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps