How I rewrote Telebugs from Rails to Rust

Kyrylo Silin
Kyrylo Silin
Telebugs founder

DHH's Rails World 2026 keynote was the push I needed to try rewriting Telebugs in Rust. I've always wanted software to be as fast as possible and use as little RAM as possible. Telebugs is a self-hosted error tracker, so getting more out of the same machine benefits the people running it too.

I used AI to write the port. It wasn't one prompt and a finished application. It took weeks of sustained work: breaking the rewrite into smaller pieces, checking results, fixing mismatches, and repeating the process.

The Rails app gave that work a concrete target: existing behavior, an existing test suite, and data that the replacement had to understand. I could run the same input through both implementations and inspect where they disagreed. That comparison kept guiding the next round of work.

The 2.0 announcement covers the RAM savings and product changes. This is how the transition worked, and what the CPU, page latency, and ingestion measurements show.

Rails did a lot of the work

Before getting into the rewrite, I want to give the original stack its due. Rails made Telebugs practical to build. It gave me a set of pieces that already worked together, so I could concentrate on the error tracker.

Puma served the Rails application through Rack, with a pool of threads handling requests. Rails supplied routing, controllers, sessions, and page rendering behind it. Puma's request threads were separate from the workers processing errors in the background.

Active Job gave those background tasks a common interface, argument serialization, and retry policies. Solid Queue stored the jobs in SQLite and managed their execution. Telebugs had queues for incoming errors, notifications, source maps, and maintenance. Recurring tasks handled retention and database upkeep. A request could hand off work durably, and that work could survive a process restart.

Solid Cache put Rails.cache in another SQLite database. Telebugs used it for ingestion counters, short-lived checks of queue pressure and free disk space, and cached project storage usage. The configured cache budget was 256 MiB. Expiration and size management came with the store, and I did not need another service just to keep these values around.

Active Record handled models, associations, validations, transactions, and encrypted settings. Action View rendered the HTML. Active Storage handled files such as note attachments and crash dumps, while Action Mailer handled email delivery through background jobs. These were useful conventions that saved me from designing each subsystem myself.

All of that work still mattered when I changed languages. The rewrite had to account for the behavior those pieces provided.

The first version was a parser

The experiment started in a separate checkout. Its first Rust crate decoded request bodies, parsed Sentry envelopes, and normalized events. It had no HTTP server, authentication, database writes, or pages.

That gave me a small, concrete comparison: feed the same input to the original Ruby classes and the Rust code, then compare their outputs. A malformed envelope needed the same treatment. An event ID needed to survive unchanged. Getting those details right gave the next piece something solid to build on.

The work then expanded into authentication, persistence, grouping, report fields, and background jobs. Rust wrote the existing SQLite schema and queue format. Rails could read the resulting records. That was useful both for checking the port and for keeping a route back to the old runtime.

Let both versions exist for a while

An early local preview put a Rust gateway in front of Rails. At first, product routes still went to Rails, with the experimental Rust ingestion path enabled separately. As more behavior was ported, the preview could exercise native handlers while Rails supplied the remaining pieces.

Local preview during the port

Request → Rust gateway → Rust handler for a ported route
                       → Rails for the remaining routes

A working page through that gateway proved very little about how much Rust could do on its own. The later checks stopped Rails before exercising the native server and workers. Incomplete paths stayed explicit during development, including jobs Rust could not yet process.

This let the application stay usable while the replacement grew. The finished service handles HTTP and background work in Rust, without a Rails fallback.

What replaced that machinery

The native HTTP server uses axum on Tokio in place of Puma and the Rails request stack. Database access goes through rusqlite. The main web dispatcher runs its synchronous database and rendering work on Tokio's blocking pool, keeping that work off the asynchronous request executor.

Background jobs still use the existing Solid Queue tables and Active Job payload formats. Rust implements the workers, dispatcher, scheduler, and supervisor, following Telebugs' queue configuration. Claiming a job, recording a failure, scheduling a retry, and recovering work after a worker exits all needed compatible behavior. The Solid Queue Ruby gem no longer runs in the service; its stored state is still understood.

The cache follows the same approach. Rust reads and writes the Solid Cache entries used by Telebugs, preserving key namespaces, hashes, encoded values, and expiry behavior. An increment needs to keep a counter's existing expiration time. Matching the resulting integer while silently extending its lifetime would change the application.

These implementations cover the contracts Telebugs uses. They also move responsibility into the application: job recovery, cache expiry, model validation, and rendering behavior are now code I have to maintain. The performance gains come with that trade-off.

Rails gave the rewrite something to answer to

The old application could answer questions that a written specification would easily miss. Which validation error appears first? What does a redirect preserve? What gets written before a background job retries? Where does keyboard focus go after a dialog closes?

I kept Rails as a reference and ported the existing test contracts. Comparisons covered HTTP responses, database effects, and browser interactions. For the UI, that meant checking the DOM, accessibility information, styles, focus, and screenshots with controlled data and clocks. The existing HTML, CSS, Turbo, and Stimulus behavior gave the port a concrete target.

Existing data made this more demanding than starting with an empty database. A Rails installation might have signed sessions, encrypted settings, uploaded files, and Active Job payloads waiting in Solid Queue. The upgrade checks carried ready and scheduled jobs into Rust and checked their identities, arguments, and effects. Recovery checks restored the matching application and data snapshot too.

One mismatch was a single SQLite microsecond. A retry deadline landed slightly later in Rust because fractional nanoseconds were rounded too early. A larger probe found differences in 3,538 of 10,000 cases. Preserving Ruby's time conversion behavior made the corrected version match all 10,000.

That difference would be hard to spot by clicking around the app. Comparing the stored values made it visible. This was where having a working Rails implementation paid off again and again.

A working server was one milestone

Rust became the main application after the newer UI work had also been ported and checked against Rails. The frontend build still depended on Rails at that point.

The Cargo workspace then moved to the repository root, and Rails moved into a separate reference directory. The new asset compiler reproduced all 545 original generated assets, including their filenames, integrity fields, and exact bytes. Normal builds could then run without loading Rails.

Shipping also meant checking the actual AMD64 and ARM64 images, updates from the released Rails application, and recovery when an update is interrupted. These checks continued after the application was already running in Rust. Each mismatch meant another fix and another round of verification before I could call the release ready.

What changed in the measurements

I compared the released Telebugs 1.23.3 Rails image with the released 2.0.1 Rust image over five rounds. Both ran on the same machine, with the same starting data, two CPUs, 2 GiB of RAM, and six default background-worker processes.

A batch of 600 authenticated page requests across six pages, using four concurrent connections, took a median 34.3 seconds in Rails and 5.7 seconds in Rust. That is about 6.0× the page throughput for this workload.

CPU use went from 61.1 to 19.2 CPU-seconds per 1,000 requests, or 68.7% less CPU time for the same work. That measures the whole application container, including background workers. It is not a comparison of instantaneous CPU percentages.

Here are the individual page timings with one connection. Each trial made 100 requests per page; the table shows the median of the five trial percentiles. The p50 is the middle request, and p95 is the point at which 95% of requests have completed.

Full HTML response latency, milliseconds
PageRails p50Rust p50Rails p95Rust p95
Projects15.26.421.47.1
Project overview78.222.9164.325.2
All errors104.618.0134.919.7
All reports34.114.540.515.6
Occurrences of one error49.416.356.518.0
Report detail40.011.148.812.0

These are full HTML responses over loopback. Assets, browser rendering, TLS, and sign-in are outside the timer. They show how quickly the server returns the page, not how long a complete page load takes over the internet.

An accepted error still has work to do

An SDK request can succeed while its report is still waiting in the queue. For ingestion, I kept the timer running until all 1,000 reports were stored and the queue was empty.

The median went from 22.6 seconds to 13.4 seconds, or 44.2 versus 74.9 stored reports per second. That is 1.7× the completed throughput. CPU use for the burst fell from 30.4 to 20.4 CPU-seconds.

Rust did not win every measurement. Receiving acknowledgments for the whole burst took a median 10.3 seconds in Rails and 12.3 seconds in Rust. The Rust version finished the work sooner overall, but those acknowledgments were slower.

There was also a slower Rust trial. It finished the complete burst in 31.7 seconds, compared with 22.6 seconds for Rails in that round. All reports were accounted for, so that run stays in the results. Rust finished sooner in four of the five paired rounds.

All five ingestion trials: seconds to store 1,000 reports and drain the queue
RoundRailsRust
121.910.2
222.631.7
322.113.9
424.712.7
523.013.4

This was a fixed burst with four connections, within the existing 3,000-events-per-minute admission policy. The throughput figure describes this workload; it is not a sustained capacity limit for every installation.

The database settings count too

Both measured versions use SQLite's WAL/NORMAL policy for the primary, queue, and cache databases. These are the connection settings used by the Rust version:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;

WAL allows readers and a writer to work concurrently, though a database still has only one writer at a time. SQLite's WAL documentation explains how that works.

With synchronous=NORMAL, most transactions avoid a disk sync at commit. Committed transactions survive application crashes, but recent commits can be lost after a power failure or operating-system crash. That is the documented durability trade-off, and it belongs beside the performance numbers.

These results compare two complete versions of Telebugs. The released pages contain different HTML, and the runtime, queries, rendering, and configuration all contribute. The benchmark does not isolate how much of the difference comes from the language alone.

Enough detail to judge the numbers

The tests ran in a local AMD64 Linux VM on an Intel i7-9750H laptop. Telebugs 1.23.3 used Rails 8.1.3.1 and Ruby 4.0.6. Telebugs 2.0.1 was compiled in release mode with Rust 1.92.0. The application had no extra swap; the load client ran separately with its own CPU and memory limits.

Every trial began with a copy of the same stopped database: one project, 2,000 reports, and 100 error groups. Each version received a 100-event warmup. Page requests used warmed pages and persistent HTTP connections. Execution order varied across rounds, and the headline figures use the median of five trials.

Across the measured runs, each version served 6,000 page requests and stored 5,000 reports. Checks accounted for every event ID, confirmed the queues had drained without failed jobs, and checked the stopped databases. External notification delivery was not configured, and CPU accounting did not subtract an idle baseline.

The host's reported CPU speed and scheduler limits stayed at 100% during the thermal checks. This was still one shared laptop and one dataset. Different data, disks, notification workloads, and traffic patterns will produce different results.

Download the benchmark results (JSON) for all five runs, including page timings, CPU use, response sizes, ingestion timings, and the test configuration. The per-trial figures retain their original precision, so you can check the medians and ratios used here.

What I kept from Rails

Rails gave Telebugs its first implementation and then gave the rewrite a working reference. That was a huge advantage. It answered questions about behavior that I would otherwise have had to rediscover.

I wanted faster software that asked less of the machine running it. Rust delivered that in these measurements. Keeping Rails around to compare against helped me get there without asking everyone to start over with an empty installation.