Frozen middleware with Rack freeze

by Atul Bhosale,

One of my favourite pastimes is to go through GitHub issues for libraries I like. One of those is the Rack gem, where I found an issue titled "Middleware should be frozen by default". A couple of questions I had were: What exactly is a frozen middleware? and why should that be done?

Example: Web request count

As a simple first example, let's consider a Rack middleware which counts the number of requests received by the server. A very simple (and broken) implementation might look like this:

Ruby

class Counter
  def initialize
    @counter = 0
  end

  def call(_env)
    counter = @counter
    sleep 1
    counter += 1
    @counter = counter
    [200, { 'Content-Type' => 'text/html' }, ["#{@counter}"]]
  end
end

The @counter instance variable gets incremented each time call method gets called, which happens for every request. If you're not familiar with what a middleware is, or how they get used, these resources might be useful:

Rack Middlewares on Railscasts Understanding Rack apps and Middleware Introduction to Rack Middleware Middleware recipes on Sinatra Recipes

Running this application in a single threaded environment results in the following output:

request_count

You can run this is in single threaded mode as -

Ruby

Rack::Server.start :app => Counter.new, server: :puma, max_threads: 1, min_threads: 1

Running this in a multi-threaded environment, however, results in the following output:

request_count

In the multi-threaded environment, the counter doesn't increment. This is called a race condition, and occurs when two or more threads can access shared data and they try to change it at the same time. Because the thread scheduling algorithm can swap between threads at any time, you don't know the order in which the threads will attempt to access the shared data. Hence, the result of the change in data is dependant on the thread scheduling algorithm i.e. both threads are racing to access/change the data.

Achieving thread-safety

When we want to avoid thread safety issues in multi-threaded environments we have some options:

  1. Not mutate the state in the middleware
  2. Freeze middleware instances to catch the thread-safety issues in the middleware that you didn't write yourself.
  3. Use data structures from the concurrent-ruby gem.

How do we do this?

Example: Web request thread-safe count

Ruby

class Counter
  def initialize
    @atomic = Concurrent::AtomicReference.new(0)
  end

  def call(_env)
    @atomic.update { |v| v + 1 }
    [200, { 'Content-Type' => 'text/html' }, ["{@atomic}"]]
  end
end

The word atomic it means that the contents of the block are executed to completion without other threads being able to read/modify the value (note that this is not same as a mutex). Multiple threads attempting to change the same AtomicReference object will not make it end up in an inconsistent state.

Freezing middleware instances

Rack middleware is initialized only on the first request of the process. So any instance variables acts like class variables, and modifying them in call() isn't thread-safe. It's necessary to dup the middleware to be thread-safe. A middleware should be frozen to avoid potential issues with handling concurrent requests. Rack recently introduced a freeze_app method to freeze middleware instances. An example usage of that would be:

Ruby

use (Class.new do
  def call(env)
    @a = 1 if env['PATH_INFO'] == '/a'
    @app.call(env)
  end
  freeze_app
end)

In this example, we are initializing an instance variable to 1 when we hit the /a url. We call freeze_app method in the middleware. When we run this program, and hit /a multiple times the freeze_app method will notify us that there is a problem by raising an exception, which you wouldn’t otherwise know:

Ruby

FrozenError: can't modify frozen #<Class:0x00007f9b0d1e95b0>

The server will respond with 200 for all other URLs because we are not modifying the instance variable in those. Internally freeze_app method calls a .freeze on the middleware instances.

Can I unfreeze a frozen object?

No, it's not possible in MRI and JRuby.

Hope you found this useful.

More articles

Protecting Your LLM Applications from Prompt Injection Attacks

Learn practical techniques to defend against prompt injection attacks in AI applications with simple code examples.

Read more

How to Read a Flame Graph in Chrome DevTools

A deep, practical guide to reading flame charts in Chrome DevTools, spotting expensive functions, and validating performance improvements.

Read more

Your competitors are already using AI.
The question is how fast you want to unlock the value?

Don't know where to start?

AI is everywhere but it's unclear which investments will actually move your metrics and which are expensive experiments.

Your data isn't ready

Most AI projects fail at the data layer. Pipelines, quality, access all need work before LLMs can deliver value.

Internal teams are stretched

Your engineers are shipping product. They don't have capacity to also become AI specialists with production-grade experience.

Legacy systems block everything

Aging, undocumented codebases make AI integration slow, risky, and expensive. They need to move first.

Don't Worry. We've got you covered.

Start with the audit.