# http-2: a pure Ruby HTTP/2 library that leaves the socket to you

> igrigorik/http-2 implements HTTP/2 and HPACK header compression in pure Ruby, with no transport of its own. It suits engineers who need frame level control and cannot justify a framework or a native extension.

**igrigorik/http-2** — Pure Ruby implementation of HTTP/2 protocol

- Repository: https://github.com/igrigorik/http-2
- Website: https://httpwg.github.io/specs/rfc7540.html
- Stars: 908 · Forks: 70
- Language: Ruby
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/igrigorik-http-2

## The library never opens a socket, so you own the wire

http-2 is a protocol implementation and nothing else. The documentation is explicit that the code makes no assumptions about how data is delivered: a regular Ruby TCP socket, a custom event loop, ZeroMQ, even the avian carriers RFC are all acceptable carriers for the same byte stream. That decision shapes everything else about the library. There is no bundled server, no client wrapper with a familiar request object, and no opinion about concurrency.

The data flow is a simple loop. Your code reads bytes from a transport and pushes them into the connection object with the `<<` operator. The parser performs the HTTP/2 decoding, the state management, and the header compression work. In the other direction, the connection emits encoded frames as byte strings that you write to the same transport. Your application logic never touches a frame header, a length field or a HPACK index.

That split is the point of the project. Engineers who need HTTP/2 inside an existing Ruby process, a non-HTTP transport, a custom proxy, or an unusual deployment where a full web framework would be dead weight, get a protocol core with a narrow surface. Everyone else pays for an integration layer they did not ask for.

## gem install http-2 and a first client in seven lines

The whole installation story is one command:

```bash
gem install http-2
```

With the gem present, the smallest useful client wires a transport into a connection and pumps bytes in both directions:

```ruby
require 'http/2'

socket = YourTransport.new

conn = HTTP2::Client.new
conn.on(:frame) {|bytes| socket << bytes }

while bytes = socket.read
 conn << bytes
end
```

`YourTransport.new` is a placeholder, not a class you can instantiate. Replace it with whatever moves bytes for you. Once that is done, the connection starts decoding the peer preface and frames as they arrive, and every frame the library wants to send comes back out through the `:frame` callback as already encoded bytes. The repository ships working versions of both roles in the example directory, with `example/client.rb` and `example/server.rb` as the starting points, so the quickest way to see a full exchange is to read those two files next to the snippet above.

## Client and Server pick their own compression and stream logic

Endpoint role is decided at construction time, and the choice is not cosmetic. Creating a `HTTP2::Client` or a `HTTP2::Server` selects the appropriate header compression and decompression algorithms and the matching stream management logic, so you never pick a compression mode yourself.

The server side exposes liveness and shutdown directly:

```ruby
server = HTTP2::Server.new

server.on(:stream) { |stream| ... } # process inbound stream
server.on(:frame)  { |bytes| ... }  # encoded HTTP/2 frames

server.ping { ... } # run liveness check, process pong response
server.goaway # send goaway frame to the client
```

The client side allocates streams and handles pushes:

```ruby
client = HTTP2::Client.new
client.on(:promise) { |stream| ... } # process push promise

stream = client.new_stream # allocate new stream
stream.headers({':method' => 'post', ...}, end_stream: false)
stream.data(payload, end_stream: true)
```

Three connection level events cover the rest. `:frame` fires once for every encoded frame that needs to go to the peer, on both roles. `:stream` is server only and fires once per new client stream. `:promise` is client only and fires once per push promise. The `goaway` call is the graceful way to end a connection, and `ping` gives you a round trip check with the pong handled in the callback.

## The stream events tell you when to stop waiting for data

The server handler example shows the event names a stream exposes, and the order of those events is what your application logic hangs on:

```ruby
conn = HTTP2::Server.new

# emits new streams opened by the client
conn.on(:stream) do |stream|
  stream.on(:active) { } # fires when stream transitions to open state
  stream.on(:close)  { } # stream is closed by client and server
```

```ruby
  stream.on(:headers) { |head| ... } # header callback
  stream.on(:data) { |chunk| ... }   # body payload callback

  # fires when client terminates its request (i.e. request finished)
  stream.on(:half_close) do
```

Two details decide whether your handler is correct. The `:headers` event fires once per received header block, and multi frame blocks are reassembled before the event is emitted, so you always see a complete header set. The `:data` event fires once per DATA frame with no buffering, which means you get the body in frame sized pieces and you are responsible for joining them if your application wants a whole body.

`:half_close` is the practical trigger for a request handler, because it fires exactly once when the peer closes its end. The full set also includes `:reserved` for a push stream that has been initialised, and `:active` for the moment a stream becomes active and is counted towards the open stream limit. `:close` fires once when both ends are done.

## Prioritization and server push are promised in a bullet list

The feature list at the top of the README claims binary framing, stream multiplexing and prioritization, connection and stream flow control, header compression, and server push. Of those, the documentation gives concrete API for framing, multiplexing, compression, push promises and flow control only in the sense that the connection object manages it.

For prioritization, the documentation offers no weight, dependency or deadline API, and the server example passes no priority arguments. If stream prioritization matters to your use case, you will have to look for it in the API docs rather than the README.

Server push is a similar half picture. The one artifact you can see is the client side `:promise` event, which tells you a push promise arrived and hands you the stream object for it. The documentation does not explain how a server decides to push, how the promised stream is served, or how pushed responses are cancelled. Treat both features as present in the protocol surface and unspecified in the documentation.

The stream lifecycle diagram in the documentation is worth reading separately, since it is the only place the idle, reserved, active, half closed and closed transitions are drawn out, including which side owns each transition.

## The example directory and h2spec releases point at a conformance effort

Based on the repository layout, this is more than a weekend parser. Alongside the `lib` directory you get a `spec` directory, a `benchmarks` directory, a `sig` directory for type signatures, a `tasks` directory, and a `CHANGELOG.md`. There is also a `h2spec-releases` directory, which suggests the project is checked against the h2spec conformance suite, and a `.rubocop.yml` with a `.rubocop_todo.yml` beside it, so the code is linted with existing violations tracked separately rather than hidden.

The examples are more revealing than the file names alone suggest. Besides `client.rb` and `server.rb` there are `raw.rb`, `helper.rb`, an `upgrade_client.rb` and `upgrade_server.rb` pair, and a `keys` directory. That spread is consistent with a library meant to be exercised in several transport shapes, but the documentation does not walk through any of them, and it does not say how the `keys` directory is used. Reading `example/README.md` and the scripts themselves is the only way to learn the intended setup.

The `example/Gemfile` is also there, so the examples run against a separate dependency set rather than the gem you install from RubyGems.

## MIT terms, a fix oriented default branch, and a release gap

The licence field on the repository says MIT, so there are no obligations attached to use, modification or distribution beyond the notice itself. Anyone embedding this in a commercial Ruby product can do so without negotiating terms, though that is a statement about the licence text and not legal advice.

The default branch is named `fix-ping-frame-when-closed`, not `master` or `main`. A plain clone therefore lands on a branch that appears to be mid fix for a ping frame on a closed connection, which is a poor default for anyone who just wants a stable starting point, and a good sign that the ping path has seen attention. Decide deliberately which branch you track.

The last push was on 2026-10-14, so the working tree is fresh. The tagged releases tell a different story: v1.1.1 on 2025-04-16, v1.1.0 on 2025-04-10, and v1.0.2 on 2024-11-05. The most recent release is therefore over a year older than the most recent commit, and the two v1.1 releases landed within a week of each other. If you depend on the packaged gem, you get the April 2025 code; if you need the newer commits, you are depending on a branch and on running from source, with the `Gemfile` and `Rakefile` in the repository to build and test it.

## Conclusion

http-2 is worth adopting when you need HTTP/2 framing inside a Ruby program and already own the transport layer, and it is the wrong choice when you want a finished server, a TLS handshake or a real transport stack. Before you build on it, check the API docs for the priority and push controls the README only lists, confirm that the default branch you clone, fix-ping-frame-when-closed, is the line you intend to ship, and decide whether the tagged v1.1.1 gem from 2025-04-16 is enough for the fixes that followed it.

## FAQ

### Does the http-2 Ruby gem handle TLS and ALPN negotiation?

The documentation does not cover TLS or ALPN negotiation. It states that the implementation makes no assumptions about how the data is delivered, so a regular Ruby TCP socket or a custom transport carries the frames, and the protocol preface and any upgrade are your concern.

### How do I install and try the http-2 gem?

The documentation gives a single command, `gem install http-2`. The repository also ships an `example/Gemfile` plus `example/client.rb` and `example/server.rb`, which are the fastest way to see a full request and response exchange.

### Does http-2 support HTTP/2 server push?

Server push is listed among the supported features, and the client role exposes a `:promise` event that fires once for each new push promise. The README does not document how a server initiates a push or how a client cancels one.

### Can I tune HTTP/2 stream prioritization with http-2?

Stream prioritization is listed among the supported features, but the documentation gives no priority API and the examples pass no priority arguments. Check the API docs for the current surface before designing around it.

## Sources

- [igrigorik/http-2 on GitHub](https://github.com/igrigorik/http-2)
- [License: MIT](https://github.com/igrigorik/http-2/blob/fix-ping-frame-when-closed/LICENSE)
- [Project website](https://httpwg.github.io/specs/rfc7540.html)
- [README](https://github.com/igrigorik/http-2/blob/fix-ping-frame-when-closed/README.md)
- [Releases](https://github.com/igrigorik/http-2/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/igrigorik-http-2
