I've been using a simple trick on updown.io since May 2015 to make pages feel faster without doing much work. And I extracted it as a gem so you can too. It's called render-later and I thought it deserved a quick write-up (better late than never).
You know that annoying moment when you're building a dashboard and there's one metric that takes 200ms to compute? Maybe it's a database aggregation, an API call to some external service, or just a heavy calculation. The rest of your page is blazing fast, but this one thing is holding everything up. Of course you can (and should) cache it, but there will always be that first uncached load which will feel slow, and that's what the user will remember.
The usual solutions are:
What if you could get the fast initial paint of the AJAX version, but with the simplicity of rendering inline? That's what render-later does.
It's quite simple: wrap your slow code in a render_later block, and it'll render an invisible <span> instead. Then at the end of your page (right before </body>), call <%= render_now %> to render all those deferred blocks and injects them in the corresponding <span> with inline JavaScript.
<div class="server">
<%= server.name %>
<%= render_later "srv-uptime-#{server.id}" do %>
<%= server.uptime %> <!-- slow database call -->
<% end %>
</div>
The magic happens when you enable HTTP streaming in your controller:
def index
render stream: true
end

Now the browser receives and renders the initial page while the server is still computing the slow parts. On a very slow network (say 3G with 500ms RTT), by the time your brother received the initial HTML, the server may have already completed the partial rendering and send it over the wire already. Because while the network was lagging, the server was working 🎉
This is the part I find interesting. With XHR/AJAX, here's what happens:
With render-later and streaming:
You're leveraging the existing open connection. No additional latency, no loading spinners, no javascript parsing, no custom error handling (if it fails, the browser shows its native error page). The browser's loading indicator stays visible until everything is done, which is exactly what you want.
I wouldn't be me if I didn't mention a few gotchas 😅
Nginx buffering: If you're behind nginx (and you probably are), you'll want to disable proxy buffering for streamed responses. Otherwise nginx batches everything together and defeats the whole purpose:
headers['X-Accel-Buffering'] = 'no'
Don't disable it globally though - that breaks caching and makes your site vulnerable to slow client attacks.
Flash & CSRF tokens: There's an annoying Rails bug where flash messages and CSRF tokens stick around forever with streaming. The workaround is to access them before rendering:
form_authenticity_token; flash
render stream: true
Rack 2.2.x ETag middleware: It broke streaming because it now processes the whole body to generate ETags. Quick fix is to set a Last-Modified header (which tells the ETag middleware to skip):
headers['Last-Modified'] = Time.now.httpdate
Server support: Not all Ruby servers support streaming. Passenger, Unicorn, and Puma work fine. WEBrick and Thin don't. In development, rails s uses Puma so you're good.
See the README for the up-to-date gotchas.
For updown.io, absolutely. Displaying up-to-date stats for dozens-hundreds of checks at once can be a bit slow. render-later lets me show the page structure instantly while the stats stream in. This way the UI feels very quick and the user can already interact with the page (e.g. edit a check) while the less critical data is loading.
The gem works on IE6+ (yes really, just DOM level 2), Rails 4.1+, and Ruby 2.3+. It has 71 stars on GitHub which means approximately 71 people found it useful enough to click a button, so I'd say mission accomplished 😄
All of that in less than 50 lines of code (inline javascript included)!
If you're dealing with a similar situation and don't want the complexity/latency of AJAX/XHR/Turbo, give it a try. Installation is just gem 'render-later' and wrapping your slow code. No JavaScript dependencies, no extra endpoints, no loading spinners, and some gotchas depending on your app setup.
And if you find issues or have ideas for improvements, PRs are welcome!
For example, parallel rendering would be cool especially for IO-bound computation, but as far as I tried the capture helper isn't thread-safe which makes it quite difficult.
Comments
Sign in to comment