Ruby and Rails Performance Roundup: The Backlog Edition

Yet again instead of tweets, a blog post. The backlog got out of hand - 40 of them this time.

Usual caveat: every number below is whatever the author measured on their own machine with their own workload. Some are microbenchmarks. Don't compare them against each other, and don't assume they'll show up in your app. Click through if you care about methodology.

byroot is still speedrunning Ruby and Rails

Jean Boussier shows up often enough that he gets a section instead of bullets scattered through the post.

  • Make Monitor a core class - giving it access to Ruby's internal routines strips out a chunk of overhead. Monitor#synchronize goes from about 19.8M to 23.7M calls a second; a plain Mutex manages around 25M on the same machine, so most of the gap is closed.
  • Optimize fixtures lookup pattern, extracted from a bigger PR. His own summary is better than anything I'd write: roughly 50% faster, but also much simpler.
  • io.c: read files in a single pass is the one I keep thinking about. File.read used to allocate a buffer, hit EOF, then enlarge and issue a second read - on the happy path, every time. Read one extra byte up front and the common case stops over-allocating. ObjectSpace.memsize_of on a 10-byte file drops from 1075 bytes retained to 40.

JIT corner

Six ZJIT and YJIT PRs from Takashi Kokubun in this batch.

  • Callsites with no profile data now recompile after a handful of side exits instead of sitting unoptimized forever. fib 5x, optcarrot 1.5x, liquid-render 14%, activerecord 5.8%.
  • Monomorphic getivar always specializes now. There was a case where the last compiled version of an ISEQ ignored a perfectly good profiled shape; fixing it is worth 1.086x on optcarrot at the current --zjit-max-versions=2 default.
  • Setivar on extended RObjects got patched up after a regression earlier in the same series. optcarrot 1384.4ms → 1064.3ms.
  • ZJIT can re-profile a whole ISEQ rather than only collecting from side exits, which means non-first compiled versions get optimized too. 1.169x on optcarrot.
  • Polymorphic invokeblock finally gets the specialize-and-compile treatment that send, getivar and setivar already had. chunky-png 17%, a loops-times microbenchmark 2.28x.
  • The last one is YJIT: a psych-load regression fixed by restoring an inline shape-transition write for the common case, an embedded object whose next shape keeps the same capacity. 1152.4ms → 980.8ms.

Two more from outside the JIT team.

  • Felix Bünemann noticed jmp_ptr_bytes() was reserving five instructions per patch point on arm64, when a single branch there reaches ±128MiB - comfortably past the 64MiB default code region. Trim the reservation to what's actually needed: a branch-heavy while loop 1.70x faster, a 3-way if/elsif loop 1.23x.
  • eightbitraptor gave string duplication the same GC-fast-path inlining that allocation already had. string-dup 277.3ms → 132.8ms, string-dup-chilled 277.5ms → 131.2ms.

super and blocks

Ractors get cheaper

Three from Koichi Sasada, all aimed at the same problem: Ractors used to make garbage collection worse the more of them you ran.

  • Per-Ractor GC is the big one. Each Ractor gets its own objspace, local GC runs without stopping the world, and a global GC only kicks in when shareable objects or dead Ractors need reclaiming. On a JSON-parsing benchmark, going from 1 to 16 Ractors cost 10.17x the single-Ractor wall time on master; with this series it's 3.32x. Forked processes manage 3.28x, so that's basically parity. Single-Ractor allocation also picks up 9% with GC disabled, since the per-Ractor newobj cache is gone.
  • The Ractor root scan was marking a thread's execution context and then marking the fiber wrapper around it, and both reach the same machine-stack scan. Skip the redundant one: a 5000-thread flood benchmark drops GC time 0.84s → 0.49s, thread creation 1.17s → 0.81s.
  • Dead Ractors used to leave their Thread/Fiber/ThreadGroup scaffolding lying around until the next major GC. Now the dying thread cleans up after itself. 500 dead Ractors leave 18 heap pages behind instead of 1016, and spawn+join throughput goes 6,224/s → 33,666/s.

Strings and arrays

  • Mari Imaizumi added a 256-byte lookup table to String#inspect so it can skip runs of unescaped ASCII rather than decoding character by character. 12.39x on ASCII text, 3.45x on mixed content. UTF-8 and binary strings unaffected; a fully escape-heavy string comes out about 5% slower.
  • When every element of an array is 7-bit ASCII, or they all share one ASCII-compatible encoding, Array#join can memcpy straight into the result buffer instead of negotiating encodings element by element. Up to 2.94x on a 100,000-element array, 2.48x with no separator at all. Yaroslav Markin.
  • #upcase and #downcase had an ASCII byte-loop fast path. #capitalize didn't, which left it roughly 10x slower than it needed to be on the same input - 5.89x faster on a single character once fixed, 12.70x on a 1000-character string.
  • Same author, second entry: annotating String#ascii_only? and #valid_encoding? as leaf builtins so they skip the CFUNC frame push. 1.2-1.4x in the plain interpreter, up to 2.08x under YJIT. Sampo Kuokkanen both times.
  • My favourite of the batch: [1,2,3].include?(x) compiled to duparray, allocating a throwaway copy of the array on every single call. A million calls now allocate 2 objects total instead of 1,000,004, and the hot path itself is 1.68x faster on top of that. Sergey Fedorov.

JSON

  • JSON::ResumableParser was fully decoding an incomplete number on every chunk that extended it - building a bignum each time and throwing it away until the number was finally complete. Defer the decode and a quadratic cost goes linear: a 128,000-digit number fed in 128-byte chunks, 3.07s → 8.13ms. That's Masataka "Pocke" Kuwabara, and at roughly 378x it's the largest ratio on this list.
  • Scott Myron ported the C parser to Java and dropped the ragel-generated one, with a SWAR and Vector API string scanner and frozen hash keys via fastASet. JRuby users get somewhere between 4.97x and 10.39x depending on the test file, best on citm_catalog.json.
  • He also widened json_decode_integer so more 19-20 digit integers hit the fast path instead of falling through to bignum. 2.04x on a file of large integers.

Two more GC fast paths

  • Ranges between two fixnums, or with a nil endpoint, don't need the generic allocation call, so Peter Zhu gave them a dedicated fast path in both new_range_fixnum and gen_new_range. 3.41x and 3.56x on tight range-allocation loops.

Rails: caches, inserts, and the little things

  • Non-STI models that don't override .new get reset back to Class.new, which drops the STI type check and unlocks Ruby 4.0's fast-path allocation. 15-17% on Ruby 4, 8-17% on 3.4 and 3.3. Mike Dalessio.
  • Andrew Novoselac batched the statements involved in creating tables instead of executing each one immediately. On his 1000-plus-table schema, load time went from about two minutes to about 25 seconds.
  • When a parameter filter is an anchored exact-match regexp like /^email$/, you can pull the literal out and check a Hash instead of testing every regexp in turn. 4.5x when all the filters are exact matches. Alex Watt.
  • this_week?, this_month? and this_year? were walking their whole range via Range#include? and its #succ iteration, which means this_year? was stepping through roughly 365 dates on every call. Range#cover? just compares endpoints. 10-100x depending on the period.
  • Gannon McGibbon deferred locale-path filtering to reload time rather than running file-stat checks eagerly at boot, which doesn't scale to apps with thousands of locale files. i18n loading in his app went 400ms → about 250ms; a synthetic benchmark with 2000+ locale files puts the old path 1.59x behind.
  • Nick Pezza found default Action Cable stream handlers being dispatched twice - once onto Action Cable's executor, then again onto the connection worker pool. Running them where they already are takes 750 subscribers from failing outright at 10 messages/second to handling it, a 35% lift over the previous 7/second ceiling.
  • ActionController::Parameters#deep_transform_keys! was rebuilding the whole parameters hash instead of mutating it, leaving an in-place helper that someone had already written sitting there uncalled. Wire it up and a 200-entry, 3-level params hash allocates 1,612 objects instead of 5,825. Kenta Ishizaki, who also has the Range#cover? fix above.

BigDecimal gets a proper algorithm

tomoya ishida replaced the naive expansion in BigMath.erf and erfc with repeated Taylor expansions at increasing precision, binary splitting each step. The gains scale with precision, so the numbers get silly at the top end: BigMath.erf(10, 100000) goes from 13.38s to 1.04s, and the worst case in the PR - erfc of a full-precision number at 100,000 digits - from 1137s, nearly nineteen minutes, to 5.27s.

Boot time and Windows I/O

Two from Hiroshi SHIBATA.

  • error_highlight, did_you_mean and syntax_suggest only enhance error output, so there's no reason to load them at boot rather than on the first Exception#detailed_message call. ruby -e1 on macOS: 114ms → 30ms.
  • File.stat on Windows was opening a full file handle - five-plus syscalls - and require's realpath resolution repeats that for every parent directory. On Windows 11 24H2 and later, a single GetFileInformationByName replaces the lot. File.stat 3.9x, require "rubocop" 1.35x, require "active_support/all" 1.55x.

Quick hits


That's the backlog cleared. Go read the ones that caught your eye; the methodology sections are usually more interesting than the numbers. Thanks to everyone above for doing the work and then writing it up.

Karafka 2.6 and Web UI 1.0: Laying the Groundwork for Kafka Queues

I'm happy to announce that Karafka 2.6 and Karafka Web UI 1.0 have just been released.

For those new here: Karafka is a Ruby and Rails multi-threaded, efficient Kafka processing framework, and its Web UI is a monitoring and management dashboard that ships alongside it. As with every release in the 2.x line, this is a continuation rather than a rewrite - you upgrade, apply a couple of small changes, and keep going.

On the surface, 2.6 is a focused set of features - redesigned Declarative Topics, dynamic worker pool scaling, a new low-level offsets API, and lag compensation for paused partitions. Underneath, it is the largest internal reorganization the framework has seen in years. Almost none of that groundwork is directly visible to you today, and that is the point: it is the foundation for where Karafka is going next.

This article covers the most significant changes rather than every one. For the full list, the Karafka changelog and Web UI changelog are the source of truth.

The Bigger Picture: Kafka Queues (KIP-932)

Let me start with the direction, because it explains most of this release.

While ago Kafka received a fundamentally new way to consume data. KIP-932 - "Queues for Kafka" - introduced Share Groups, a cooperative model that sits alongside the classic consumer group. Instead of partitions being exclusively assigned to a single consumer, share groups let multiple consumers cooperatively pull from the same partitions and acknowledge individual records. In practice, this brings queue-like semantics to Kafka: work-queue fan-out, per-message acknowledgement, and consumer counts no longer capped by partition count.

The rest of the stack is now catching up to the broker. librdkafka is gaining Share Group support, and I'm building the rdkafka-ruby bindings for it as we speak - the layer Karafka sits on top of. Bringing this all the way up into Karafka is a multi-step journey across the whole stack, and 2.6 is where the framework's part of it begins.

For a framework like Karafka, this is not a small bolt-on. Consumer groups are woven into the fabric of the processing, routing, connection, and instrumentation layers. Share groups need their own parallel strategies, coordinators, jobs, and callbacks - and layering a second, coexisting group type on top cleanly is only possible once the consumer-group-specific code is isolated in its own namespace.

Share Groups are not in 2.6. What is in 2.6 is the mandatory first step: Karafka reorganizes all of those layers into consistent ConsumerGroups namespaces, introduces group-type-agnostic routing accessors, and threads a parallel group / group_id vocabulary through instrumentation payloads. It is deliberately invisible plumbing - and it is what makes Kafka Queues in Karafka tractable rather than a rewrite.

Sponsorship and Community Support

Karafka's progress continues to be powered by the people and companies who fund it, report issues, review pull requests, and run it in production at a scale I could never reproduce alone. To everyone who sponsors the project, contributes code, files detailed bug reports, or helps another user in Slack: thank you. The scope here - dozens of fixes, a major internal reorganization, and the beginning of the Share Groups journey - is only sustainable because Karafka Pro and Enterprise customers let me treat this as serious, ongoing engineering. The more successful the commercial side becomes, the more I can give back to OSS.

Karafka Framework

Redesigned Declarative Topics

The Declarative Topics system now lives in a standalone declaratives.draw DSL, independent of routing.

This resolves a mismatch that grew as adoption spread: many teams use Karafka as the single source of truth for their entire topic infrastructure, not just the topics they consume. Previously, declarations were embedded in routing - so managing a topic (another team's service, a shared audit log, a produce-only sink) meant adding it to your routing, implying consumption intent and dragging all the consumer machinery along. Separating them makes each purpose explicit: routing describes what you consume and how, declaratives describe what topics exist and how they're configured.

class KarafkaApp < Karafka::App
  declaratives.draw do
    defaults do
      replication_factor 3
    end

    topic :orders do
      partitions 6
      config('retention.ms': 86_400_000)
    end

    # Produce-only or owned elsewhere - no routing entry needed
    topic :audit_log do
      partitions 3
      config('cleanup.policy': 'compact')
    end
  end

  routes.draw do
    topic :orders do
      consumer OrdersConsumer
    end
  end
end

The old routing-based config() approach is deprecated but still works in 2.6, so there is no forced migration.

Dynamic Worker Pool Scaling

The worker thread pool can now be scaled at runtime without restarting - handy for time-of-day load, external signals, or ramping up after a deploy.

Karafka::Server.workers.scale(10) # add threads immediately (synchronous)
Karafka::Server.workers.scale(3)  # drain down gracefully (asynchronous)
Karafka::Server.workers.size

Scaling up is synchronous; scaling down lets workers exit as they finish in-flight jobs. Both directions emit worker.scaling.up / worker.scaling.down events. config.concurrency still sets the initial pool size at boot.

Lag Compensation for Long-Paused Partitions

librdkafka refreshes watermark offsets and lag only from fetch responses, so a long-paused partition reports frozen lag in statistics.emitted - and in everything built on it, including the Web UI. Karafka Pro can now compensate: when enabled, it periodically refreshes the watermarks and lags of long-paused partitions through the running connection and overlays them onto the emitted statistics, handing back to live stats on resume. It's opt-in and off by default for now:

config.internal.statistics.consumer_groups.lag_compensation.interval = 30_000
config.internal.statistics.consumer_groups.lag_compensation.pause_age = 30_000

More Robust Error Handling

Two behavioral changes landed in the consumption error path.

  • Non-StandardError exceptions (like ScriptError) are no longer silently skipped - they flow through the normal retry / pause / DLQ path.
  • Process-critical errors (SystemExit, SignalException, NoMemoryError) are recorded, keep the partition paused, and trigger a graceful shutdown via the auto-subscribed Instrumentation::CriticalErrorsListener rather than being retried or dispatched to a DLQ.

If your consumers can raise non-StandardError exceptions, the outcome now differs from 2.5.

Performance and Ractors

Several internal admin operations that issued N sequential per-partition calls are now single batched calls - watermark reads resolve in two calls regardless of partition count. This is why 2.6 requires karafka-rdkafka >= 0.28.0, which exposes list_offsets and rebuilds consumer #lag on top of it.

Ractor-based parallel deserialization is implemented and works, but I've deliberately held it back from 2.6. This release already carries a large volume of internal change, and stacking Ractors on top would make it much harder to reason about anything that surfaces in production. They'll ship once the 2.6 internals settle - I'd rather isolate risk than bundle two big unknowns into one version.

Karafka Web UI 1.0

After a long run of production-hardened 0.x releases, the Web UI graduates to 1.0.

Why 1.0, and Why Now

The 0.x numbering was always a conservative signal, not a reflection of stability. The Web UI has been production-ready and free of major breaking changes for a very long time, so 1.0 is, first and foremost, an honest version number. There are only small breaking changes in the configuration between 0.11.7 and 1.0. From 3.0 onward, Web UI versioning will align with Karafka's major version and move in lockstep.

Modernized CSRF Protection

The old token-based CSRF approach (route_csrf) is replaced with header-based protection using the browser-enforced Sec-Fetch-Site header (sec_fetch_site_csrf). Modern browsers always send it and it can't be forged cross-origin, so protection is simpler and more robust - with no tokens to thread through your views. For virtually everyone this is transparent; only non-browser clients hitting unsafe methods directly must send Sec-Fetch-Site: same-origin.

Better Monitoring and a More Consistent Interface

  • Poll interval monitoring: consumer reporting now tracks poll_interval (max.poll.interval.ms) per subscription group, so you can catch a slow consumer before Kafka evicts it. (Consumer schema bumped to 1.7.0.)
  • Runtime-aware worker count: the UI reads the live count from Karafka::Server.workers.size, reflecting dynamic pool scaling accurately.
  • Consistency and polish:
    • a standardized empty-state component across every list view
    • topic/partition/offset coordinates in the Explorer and Errors views now link straight to the relevant message
    • Pro gating hardened so a misclick no longer navigates away to an upsell page; rel="noopener noreferrer" on every external link
    • and a whole class of overflow bugs from long topic names fixed.

Dozens of Bug Fixes

Beyond the headline features, this release is genuinely fix-heavy - roughly 30 fixes in Karafka 2.6 and around 25 more in Web UI 1.0. A representative sample from the framework:

  • Reset the per-partition retry counter on revocation, so a message reclaimed after a rebalance isn't wrongly treated as retry-exhausted and dispatched to the DLQ early.
  • Return false from #mark_as_consumed / #commit_offsets! when the partition was lost (previously could return true in some cases).
  • Reset seek_offset only after a successful #seek, so a raising seek no longer skips the rest of a batch.
  • Leave tombstone records untouched on encrypted produce/consume instead of crashing, keeping them valid for log compaction (Pro).
  • Reset ActiveJob CurrentAttributes in an ensure, so a failed job's attributes no longer leak into the next job.

Upgrade Notes

The 2.52.6 upgrade is intentionally small for most applications, but there are a few breaking changes, behavioral shifts, and internal namespace moves worth knowing about before you deploy. Web UI 1.0 requires Karafka 2.6, so upgrade them together. Rather than repeat it all here, read the Karafka 2.6 upgrade guide and the Web UI 1.0 upgrade guide - they walk through every required action step by step.

Karafka Pro

Much of the deepest work here - lag compensation, batched Pro iterator resolution, granular backoff correctness, virtual-partition DLQ ordering fixes - lives in Karafka Pro. Pro is what funds this pace of development and how I prioritize the flood of questions and edge cases the community brings. If Karafka is load-bearing infrastructure for you, Karafka Pro pays for itself in features and support - and directly funds the OSS work, including the Share Groups journey ahead.

Summary

Karafka 2.6 is a foundation release. Its visible features - declarative topics, dynamic worker scaling, the offsets API, Pro lag compensation - are useful on their own, but its most important work is the internal reorganization that clears the path for Kafka Queues / Share Groups (KIP-932). Pair that with a 1.0 Web UI that finally carries a version number matching its maturity, plus over fifty bug fixes across both projects, and this release makes the whole ecosystem more solid while setting up what's next.

Thank you to everyone who makes that possible.

References

Want to follow the Share Groups work as it lands? Join us in the Karafka Slack channel.

Copyright © 2026 Closer to Code

Theme by Anders NorenUp ↑