Ruby on Rails Concurrency: Understanding Puma, Threads, Workers and Database Connection Pools

One of the most common Rails questions is:

How does Puma handle multiple HTTP requests? How do threads work and what happens when multiple requests access the database simultaneously?

To understand this properly, we need to understand four concepts:

  1. Processes and threads.
  2. I/O-bound requests.
  3. Database connections and connection pools.
  4. Database locking and concurrent queries.

Let’s start from the beginning.

1. Why Did Rails Move from WEBrick to Puma?

Historically, WEBrick was commonly used as the default Rails development server.

With Rails 5.0, Rails changed its default development server from WEBrick to Puma.

One important reason was the introduction of Action Cable in Rails 5.

Action Cable introduced WebSocket support for real-time features such as chat, notifications and presence. It needed to operate alongside the Rails application in development.

Puma provided a better fit for this concurrent application-server architecture.

Puma provides:

  • Multithreading: Handles multiple requests using separate threads.
  • Multiprocessing: Supports multiple worker processes.
  • Better concurrency: Allows requests waiting for I/O to overlap.
  • Copy-on-write memory optimisation when using application preloading.
  • Production-oriented server capabilities.

One clarification: WEBrick could also create threads for requests. The change wasn’t simply because WEBrick could only process one request at a time. Puma offered a more suitable combination of thread-based and process-based concurrency.

Also, WEBrick’s removal from Ruby’s standard library in Ruby 3.0 was a separate event, not the original reason Rails moved to Puma.

Q) Does puma developed as a ruby library? Or puma has been written in another language and created in ruby as a gem?

Yes! Puma is a Ruby web server, distributed as a Ruby gem, and primarily written in Ruby.

It is not a web server written in another language with a Ruby wrapper.

You install it directly into a Rails application:

# Gemfile
gem "puma"

And start it using:

bundle exec puma

Puma runs inside the Ruby runtime (MRI/CRuby, JRuby, etc.). It uses Ruby’s threading and process capabilities to handle concurrent HTTP requests.

There is one interesting implementation detail: Puma is primarily Ruby, but it also uses native code for performance-critical components.

For example:

  • Most server logic is implemented in Ruby.
  • Its HTTP parser is based on Mongrel’s parser, implemented using Ragel and compiled to native C (or Java for certain Ruby implementations).
  • It uses Ruby’s thread and process APIs to manage concurrent requests.

How does Puma fit into Rails?

              HTTP Request
                   |
                   v
              Puma Server
             (Ruby Gem)
                   |
             Rack Interface
                   |
                   v
           Rails Middleware
                   |
                   v
             Rails Router
                   |
                   v
          Controller / Model
                   |
                   v
              PostgreSQL

Puma is not part of Ruby itself, nor is it part of the Rails framework core. It is an independent Ruby gem that serves Rack applications, including Rails.

Int. answer:

Puma is a Ruby-based, multithreaded and multiprocess HTTP server distributed as a Ruby gem. It implements the Rack interface, allowing it to serve Rails applications. Most of its implementation is Ruby, with native extensions for performance-critical components such as HTTP parsing.

You can also explore its actual source code: Puma GitHub Repository. It’s a useful project to study as a senior Ruby developer.

2. What Is the Difference Between a Process and a Thread?

This is fundamental to understanding Puma.

Process

A process is an independent running instance of an application.

Each process has its own:

  • Memory space.
  • Ruby interpreter state.
  • Global VM Lock (GVL) under MRI Ruby.
  • Active Record connection pool.

Processes are isolated from one another.

Thread

A thread is an execution unit running inside a process.

Multiple threads within the same process share:

  • Application memory.
  • Loaded classes and modules.
  • Global variables.
  • Other process-level resources.

However, each thread has its own execution context and can handle a different HTTP request.

Example

Suppose Puma is configured with:

workers 2
threads 3, 3

This means:

  • 2 worker processes.
  • 3 request threads per worker.

Total request-thread capacity:

2 workers × 3 threads = 6 concurrent requests

Conceptually:

                 Puma Master Process
                         |
              ---------------------
              |                   |
         Worker 1             Worker 2
         Process               Process
              |                   |
         -----------          -----------
         |    |    |          |    |    |
        T1   T2   T3          T1   T2   T3

        3 threads             3 threads

             Total: 6 request threads

A worker is a process, not a thread.

This distinction is extremely important in ints.

3. What Is the Global VM Lock (GVL)?

When using MRI Ruby (the standard CRuby implementation), Ruby has a Global VM Lock.

The GVL ensures that only one thread within a process executes Ruby code at a time.

For example:

Thread.new do
  1_000_000.times { |i| i * 2 }
end

Thread.new do
  1_000_000.times { |i| i * 2 }
end

Although two threads exist, they cannot execute Ruby bytecode simultaneously within the same MRI process.

Then why do we need multiple threads?

Because not all application work involves executing Ruby code.

A Rails application spends significant time waiting for external operations:

  • Database queries.
  • HTTP requests to third-party APIs.
  • Redis operations.
  • File and network I/O.

During blocking I/O, Ruby can release the GVL, allowing another thread to execute.

Important distinction:

  • Threads improve concurrency by overlapping waiting time.
  • Multiple processes allow MRI Ruby code to execute in parallel across CPU cores.

Puma supports both.

4. What Are I/O-Bound Requests?

An I/O-bound request spends a significant portion of its execution time waiting for an external operation to complete.

Consider this Rails action:

def show
  user = User.find(params[:id])

  response = HTTParty.get(
    "https://api.example.com/profile"
  )

  render json: {
    user: user,
    external_profile: response.parsed_response
  }
end

Imagine:

OperationTime
Ruby processing10 ms
Database query50 ms
External API request500 ms
JSON response processing10 ms
Total570 ms

The application spends only 20 ms actively processing Ruby code.

The remaining 550 ms is waiting for external operations.

This is an I/O-bound request.

How do Puma threads help?

Imagine three requests arrive simultaneously.

Without useful concurrency, a single execution path might spend time waiting for each external API response.

With Puma:

Thread 1: Ruby code -> Wait for API -> Resume -> Response
Thread 2: Ruby code -> Wait for API -> Resume -> Response
Thread 3: Ruby code -> Wait for API -> Resume -> Response

While Thread 1 waits for its API response, Thread 2 or Thread 3 can execute.

The server uses waiting time more efficiently.

This improves throughput and reduces unnecessary request queuing.

What about CPU-bound requests?

Consider:

def calculate
  result = 0

  100_000_000.times do |i|
    result += i * 2
  end

  render json: { result: result }
end

This request spends most of its time executing Ruby code.

Increasing Puma threads does not provide true parallel Ruby execution within one MRI process because of the GVL.

For CPU-heavy Ruby workloads, multiple worker processes are more useful because each process has its own GVL.

5. Does Accessing the Database Lock It for Other Requests?

This is an important question.

No. A database connection or a database query does not automatically lock the entire database.

We need to distinguish three different things:

  1. Database connection.
  2. Database connection pool.
  3. Database locks.

They are not the same.

Database connections

A database connection represents a communication session between your Rails process and PostgreSQL.

Multiple connections can exist simultaneously.

For example:

Rails Thread 1 ---- DB Connection 1 ---- PostgreSQL
Rails Thread 2 ---- DB Connection 2 ---- PostgreSQL
Rails Thread 3 ---- DB Connection 3 ---- PostgreSQL

Each connection can execute its own SQL queries.

PostgreSQL is designed to handle concurrent database sessions.

What happens when two requests access the same table?

Consider two requests:

# Request 1
User.find(10)

# Request 2
User.find(20)

Both requests can read users simultaneously.

They don’t block each other simply because they access the same table.

PostgreSQL uses MVCC (Multi-Version Concurrency Control).

MVCC allows transactions to read consistent snapshots of data while other transactions are modifying records.

For normal PostgreSQL reads, a SELECT does not generally block an UPDATE and an UPDATE does not generally block a normal SELECT.

What happens when two requests update the same record?

Consider:

# Request 1
Account.find(10).update!(balance: 500)

# Request 2
Account.find(10).update!(balance: 300)

If both transactions try to update the same row concurrently, PostgreSQL may make one transaction wait for the other to finish.

This is because PostgreSQL uses row-level locking for conflicting writes.

For example:

Transaction 1:
    UPDATE accounts SET balance = 500 WHERE id = 10

    Acquires row lock

Transaction 2:
    UPDATE accounts SET balance = 300 WHERE id = 10

    Waits for Transaction 1

Transaction 1:
    COMMIT

Transaction 2:
    Continues

This prevents conflicting updates from being applied simultaneously without coordination.

You can also explicitly lock records using:

Account.transaction do
  account = Account.lock.find(10)

  account.update!(
    balance: account.balance - 100
  )
end

This uses PostgreSQL’s row-locking mechanism through SELECT ... FOR UPDATE.

Remember:

  • A database connection is not a database lock.
  • Multiple database connections can execute queries concurrently.
  • Conflicting writes may wait for row locks.
  • Normal SELECT queries generally don’t block writes in PostgreSQL because of MVCC.

6. What Is an Active Record Connection Pool?

Now let’s connect Puma threads with the database.

Creating a new database connection for every SQL query would be expensive.

Instead, Rails maintains a pool of reusable database connections.

This is called the Active Record Connection Pool.

Consider:

# config/database.yml

production:
  adapter: postgresql
  database: my_application_production
  pool: 5
  checkout_timeout: 5

The pool: 5 setting means that this Active Record connection pool can provide up to five simultaneous database connections.

It doesn’t mean that only five requests can execute in Rails.

It means that only five threads can hold a connection from this particular pool at the same time.

How does the connection pool work?

Suppose three Puma threads receive requests.

Puma Worker Process
       |
       |
  Active Record
  Connection Pool
       |
  ---------------------
  |         |         |
 DB Conn 1  DB Conn 2  DB Conn 3
  |         |         |
  ---------------------
       |
   PostgreSQL

Here’s what happens:

  1. Thread 1 receives a request.
  2. Thread 1 executes an Active Record query.
  3. Active Record checks out a connection from the pool.
  4. The connection executes SQL against PostgreSQL.
  5. The request completes.
  6. Rails returns the acquired connection to the pool.

The connection becomes available to another thread.

The pool creates connections as needed, up to its configured limit.

What happens if all connections are busy?

Suppose:

pool: 2

But three threads simultaneously need database connections.

Thread 1 -> Connection 1 (Busy)
Thread 2 -> Connection 2 (Busy)
Thread 3 -> Waiting for a connection

Thread 3 waits until one of the connections becomes available.

If it cannot obtain a connection before checkout_timeout expires, Rails raises:

ActiveRecord::ConnectionTimeoutError

This is a common production issue when the connection pool is too small for the application’s database workload.

7. How Do Puma Workers and Active Record Connection Pools Work Together?

This is where everything comes together.

Remember that every Puma worker is a separate process.

Each worker process maintains its own Active Record connection pool.

For example:

# config/puma.rb

workers 3
threads 5, 5

And:

# config/database.yml

production:
  adapter: postgresql
  database: my_application_production
  pool: 5

Let’s calculate the capacity.

ComponentValue
Puma worker processes3
Threads per worker5
Total request threads15
DB connections per worker5
Total potential DB connections15

The calculation:

3 Puma workers × 5 DB connections = 15 DB connections

Each worker has its own connection pool.

                 Puma Master
                      |
          -------------------------
          |           |           |
       Worker 1    Worker 2    Worker 3
          |           |           |
       Pool 1      Pool 2      Pool 3
        (5)         (5)         (5)
          |           |           |
          -------- PostgreSQL -----

Notice that the three pools are independent.

A connection checked out by a thread in Worker 1 cannot be used by a thread in Worker 2.

What if we configure 5 Puma threads but only 2 DB connections?

workers 1
threads 5, 5
pool: 2

Five requests can be in progress, but only two threads can hold database connections simultaneously.

The remaining threads may wait for an available connection.

This can increase response latency and eventually cause connection timeouts.

What should the connection pool size be?

A useful starting rule is:

Set the connection pool at least as large as the maximum number of concurrent threads that need database connections in that process.

For example:

threads 5, 5

A pool size of five is a sensible starting point.

You may need a larger pool when using additional concurrent database consumers, such as asynchronous Active Record query executors.

You don’t always need one database connection for every Puma thread if only some requests access the database. A smaller pool is possible, but it introduces connection-pool contention.

What about Sidekiq?

Sidekiq is another process that can execute multiple jobs concurrently.

Suppose:

# config/sidekiq.yml
:concurrency: 10

If ten Sidekiq threads can execute database operations simultaneously, its Active Record connection pool should generally support at least ten connections per relevant database pool.

Remember:

  • Puma has its own database pool per worker process.
  • Sidekiq has its own database pool per process.
  • Console sessions and other application processes also consume database connections.

Always calculate total possible database connections across the entire application deployment.

A useful formula is:

Total DB connections =
  (Puma workers × Puma pool size)
  + (Sidekiq processes × Sidekiq pool size)
  + Other application connections

This total must fit within your database connection capacity, leaving room for administration and other required sessions.

8. How Can We Configure Puma for Better Rails Performance?

Puma configuration mainly involves two important settings:

  1. Number of worker processes.
  2. Number of threads per worker.

Example configuration

# config/puma.rb

max_threads = ENV.fetch("RAILS_MAX_THREADS", 3).to_i

min_threads = ENV.fetch(
  "RAILS_MIN_THREADS",
  max_threads
).to_i

threads min_threads, max_threads

workers ENV.fetch("WEB_CONCURRENCY", 2).to_i

preload_app!

Start the application:

bundle exec puma -C config/puma.rb

A. Configure the number of threads

threads 3, 3

This configures three request threads per worker.

You can increase the thread count for applications with substantial I/O waiting.

However, don’t blindly increase it.

More threads can increase:

  • Memory consumption.
  • GVL contention.
  • Database connection demand.
  • External service concurrency.

Rails 7.2 changed its generated Puma thread default from five to three after observing that excessive threading could increase GVL contention and request latency.

B. Configure the number of workers

workers 2

This starts two worker processes.

Each process has its own Ruby execution environment and GVL.

Multiple workers allow MRI Ruby code to execute in parallel on different CPU cores.

However, workers also consume memory and increase the total number of possible database connections.

Don’t blindly set workers equal to the number of available CPUs without considering memory and database capacity.

C. Application preloading

preload_app!

This loads the application before Puma forks its worker processes.

It can reduce memory usage through an operating-system feature called Copy-on-Write (CoW).

When memory pages are shared and neither process modifies them, the operating system can avoid duplicating those pages.

Preloading is useful in clustered deployments, but it has lifecycle implications. In particular, it cannot be combined with Puma’s phased restart feature.

When configuring preloading, follow the connection and background-thread lifecycle requirements of your Puma version and Rails application.

D. What is the best configuration?

There is no universal best configuration.

For a typical I/O-heavy Rails application, start with a modest number of threads and workers, then benchmark.

For example:

workers 2
threads 3, 3

This provides six simultaneous request slots.

Measure:

  • Request throughput (requests per second).
  • P95 and P99 response latency.
  • Worker memory consumption.
  • CPU utilisation.
  • Database connection-pool wait time.
  • Database CPU and query latency.

Increase workers or threads only when measurements show that the current configuration is limiting performance.

9. Putting Everything Together

Let’s consider a real-world example.

A Rails application receives 100 HTTP requests.

Puma is configured with:

workers 2
threads 3, 3

The application can process six requests concurrently across its two worker processes.

The remaining requests wait in the server’s request-handling queue.

Now assume every request needs a database connection.

Each worker has:

pool: 3

Therefore:

Worker 1: 3 concurrent DB connections
Worker 2: 3 concurrent DB connections
Total: 6 possible DB connections

PostgreSQL can execute queries using these independent connections.

If one transaction updates a particular row, another transaction attempting to update the same row may wait for the lock to be released.

Meanwhile, other unrelated queries can continue.

This is how Puma, Active Record and PostgreSQL work together to handle concurrent Rails requests.

10. Common Interview Questions

1. What is the difference between a Puma worker and a thread?

A worker is an independent operating-system process. A thread is an execution unit inside that process. Puma uses both to handle concurrent requests.

2. What are I/O-bound requests?

Requests that spend considerable time waiting for external operations such as database queries, HTTP calls, Redis, or file I/O.

3. Does MRI Ruby support multithreading?

Yes. MRI supports multiple threads, but the GVL prevents multiple threads in the same process from executing Ruby bytecode simultaneously. Threads can still overlap blocking I/O.

4. Why does Puma use multiple processes?

Multiple processes allow Ruby code to execute in parallel across CPU cores despite the MRI GVL. They also provide process isolation.

5. Does every Puma thread need a database connection?

Not necessarily at every moment. However, when multiple threads simultaneously require database connections, each needs its own checked-out connection.

6. What happens when the connection pool is exhausted?

Threads wait for a connection. If one does not become available before the checkout timeout, Rails raises ActiveRecord::ConnectionTimeoutError.

7. Does PostgreSQL lock an entire table when a record is updated?

Not normally. PostgreSQL uses MVCC and row-level locking for conflicting updates. Certain operations, such as some DDL commands, can acquire table-level locks.

8. Why not increase the number of Puma threads to 100?

Too many threads can increase GVL contention, memory usage, database connection requirements and request latency. Thread count must be tuned based on the workload.

9. What is the difference between concurrency and parallelism?

Concurrency means multiple tasks make progress during overlapping periods. Parallelism means multiple tasks execute simultaneously, typically on different CPU cores.

10. How do you troubleshoot Active Record connection-pool exhaustion?

Check the configured pool size, Puma thread count, Sidekiq concurrency, connection checkout wait time, long-running transactions and database connection limits. Increase the pool only after checking total database capacity.

🏁 Final Takeaway

Remember these relationships:

  • Puma workers = independent processes.
  • Puma threads = concurrent request execution within each process.
  • MRI GVL = one thread executes Ruby bytecode at a time per process.
  • I/O-bound operations = threads can make progress while another thread waits.
  • Active Record connection pool = manages reusable database connections for threads.
  • Database connection = a communication session, not a database lock.
  • PostgreSQL MVCC = allows concurrent access to consistent data snapshots.
  • Database row locks = coordinate conflicting writes.

Once you understand how these components interact, Puma configuration and Rails concurrency become much easier to reason about.

Happy Configuring!

Unknown's avatar

Author: Abhilash

Hi, I’m Abhilash! A seasoned web developer with 15 years of experience specializing in Ruby and Ruby on Rails. Since 2010, I’ve built scalable, robust web applications and worked with frameworks like Angular, Sinatra, Laravel, Node.js, Vue and React. Passionate about clean, maintainable code and continuous learning, I share insights, tutorials, and experiences here. Let’s explore the ever-evolving world of web development together!

Leave a Reply