As we wrote the day we released River, we love Go. When Blake and I first learned it in the early 2010s, it was such a delight – unreasonably fast, great type checking, blazing compilation speeds, batteries included standard library, and fully integrated toolchain. We boarded that train and never got off.
That love for Go isn’t going to change anytime soon, but to give River its best chance of staying relevant, we’re expanding its reach. The world’s changing fast, and it seems unwise to tie your identity too closely to one language or ecosystem.
Today, we’re adding three new full River implementations: Rust, TypeScript, and Ruby. Each supports one hundred percent of River’s existing feature set (periodic jobs, cron, multiple queues, pause/resume, snoozing, etc.), and both Postgres and SQLite. They’ll be maintained in lockstep with the original Go.
Interoperable and native
Applications written in Go, Rust, TypeScript, and Ruby can share a database, all inserting and working jobs from the same River schema. Even leader election works across languages – when a Go leader steps down, a Rust client might win next and take over queue maintenance.
Our hope is that organizations can adopt River in one component, then migrate the rest incrementally. The new FetchOnlyKnownKinds option lets clients fetch only jobs they’re configured to run, so different languages can share a queue as individual jobs move from one stack to another.
You can take a look at a demo of River UI to see a running example of clients in four different languages sharing the default queue:


Each client is a full implementation written from the ground up in its own language. There’s no shared Go “core” daemon. Every feature in Rust is only implemented in Rust. Likewise, every feature in Ruby is implemented in Ruby. You can use any of the new clients without so much as looking at the Go ecosystem.
LLM-driven development and maintenance
We’d talked years ago about expanding River into other language ecosystems, but the development and maintenance would have been too much for our two-person team.
LLMs changed that completely. We used Opus 5.5 and GPT-6 Sol/Astra to transliterate our Go source to Rust, TypeScript, and Ruby, then ran dozens of refinement passes. We built a conformance suite to verify database-level compatibility across languages, and spent considerable time reviewing and iterating on the public-facing APIs ourselves to make sure they met our standards. All in all it took hundreds of hours, but that’s down considerably from the months/years it would’ve taken before.
The LLMs will also help with maintenance – we’ll implement new features in Go and have them create working versions in all other languages in parallel so we can easily check that what works in one works in all. Upkeep and release processes will be automated. This will flatten associated effort and bring top tier maintenance within reach.
Why Rust?
Source code · Package on crates.io
Rust in particular is more intriguing than anything else right now. It’s had great bones forever – fast, terse, and memory safe – but a steep learning curve. Now AI can write Rust for you and plough through that learning curve like a brick through a plate glass window. There’s a very real chance its modern conventions, incredible speed, and tiny memory footprint make it a lingua franca for the LLM age.
Syntax highlights
Job definitions with attributes
Rust attributes make for drop dead gorgeous job args definitions, a major step up over Go. This unique(...) annotation keeps uniqueness explicit but terse, nested right alongside other configuration:
#[derive(Clone, Debug, Deserialize, JobArgs, Serialize)]#[river(kind = "reconcile_account", unique(by_args, by_period = "1h"))]struct ReconcileAccount { #[river(unique)] account_id: i64, requested_by: String,}Worker outcomes with Result
Rust’s Result type is phenomenal for controlling program flow in jobs. Normal completion returns Ok(WorkOutcome::Complete), but in case an outcome needs to take an argument like a snooze duration, that works perfectly. Unlike in Go where we abuse errors a little for best possible ergonomics, in Rust the error option is reserved exclusively for error conditions:
Ok(WorkOutcome::Complete)Ok(WorkOutcome::Snooze(Duration::from_secs(30)))Ok(WorkOutcome::Cancel)Err(error)Typed job output
Especially where workflows are concerned, job output is important for passing information from one job to another. Rust provides a way of accessing output in a type-checked way, helping to find more problems at compile time:
let output = FraudScoreOutput { score: 92 };context.record_output(&output)?;
// Inside the downstream worker:let score = deps.output::<FraudScoreOutput>("score_fraud")?;Why TypeScript?
Up until a few months ago it looked like a very possible future was that all software would trend toward being written in JavaScript/TypeScript. JavaScript’s always been integral to the web experience, but the addition of TypeScript and the tireless community created a monster that was on its way to eating the entire world.
With LLMs all bets are off, but TypeScript is still undoubtedly gigantic and a very safe investment for a product like River aiming to support the world’s most vibrant developer ecosystems.
Syntax highlights
Argument validation with schemas
Zod or another Standard Schema-compatible library validates arguments on insert and work. These checks go beyond types – here, we check that a string has a valid email format:
const sendEmail = defineJob({ kind: "send_email", schema: z.object({ to: z.email() }),});
const workers = new Workers().add(sendEmail, async ({ job }) => { await mailer.send(job.args.to); // Inferred as string.});Typed multi-insert results
A really neat trick: a multi-insert preserves each returned job’s type, even when job arguments have completely different structures:
const [email, account] = await client.insertMany([ { job: sendEmail, args: { to: "person@example.com" } }, { job: syncAccount, args: { accountId: "acct_123" } },]);
email.job.args.to; // stringaccount.job.args.accountId; // stringWorker outcomes as return values
Worker outcomes are expressed as standard returns to control program flow. This keeps worker code looking quite clean even when using a special outcome like cancel or snooze:
workers.add(sendEmail, async ({ job }) => { if (await recipientDeleted(job.args.to)) { return cancel({ reason: "Recipient deleted" }); } if (await rateLimited()) return snooze({ seconds: 30 });
const messageId = await mailer.send(job.args.to); return complete({ output: { messageId } });});Why Ruby?
Source code · Package on RubyGems
Back when we released insert-only Ruby support we talked about how important Ruby was to our original trajectory as software engineers, and how it’s still a solid language with a great community.
The background job ecosystem in Ruby is crowded to say the least, with a first party option even available with Rails nowadays. You could make a strong case that getting into Ruby at this point isn’t a sound business decision and you’d be right – we’re doing it for the love of the game. That said, none of River Pro’s workflows, signals, timers, sequences, and batching are currently supported by Solid Queue, so River’s already comparing favorably to incumbents.
Syntax highlights
RSpec matchers
A library fairly unique to Ruby’s ecosystem is RSpec. River’s matchers let you compare complex objects with assertions that look good and are easy to understand:
expect { place_order }.to insert_job( client, kind: :fulfill_order, queue: :orders, priority: be_between(1, 2).inclusive, args: a_hash_including( "order_id" => order.id, "items" => all(a_hash_including("quantity" => be > 0)) ), metadata: a_hash_including( "source" => "checkout", "request_id" => a_string_matching(/\Areq_/) ), tags: include("checkout", "paid"), scheduled_at: be_within(2).of(expected_time))Resumable steps with blocks
Resumable jobs in Ruby are quite handsome when combined with the language’s block syntax. All step bookkeeping is handled by River with no additional syntax required. Unlike Go, if an error occurs in any step, exception-based flow control skips subsequent steps automatically:
def work(job) job.resumable_step(:download) { download(job.args) } job.resumable_step(:import) { import(job.args) } job.resumable_step(:notify) { notify(job.args) }endWorkflow builders
River Pro’s workflows look pretty great. A builder-like pattern ensures that each task gets a name, but tasks still get to reference each other in a refactor-safe way:
flow = River::Pro.workflow(name: "media_import") do |w| download = w.add(:download, DownloadArgs.new)
thumbnail = w.add(:thumbnail, ThumbnailArgs.new, after: download) index = w.add(:index, IndexArgs.new, after: download)
publish = w.add(:publish, PublishArgs.new, after: [thumbnail, index]) w.add(:notify, NotifyArgs.new, after: publish)endPro: Code complete and available soon
We’ve implemented every Pro feature for each new language, though we’re still working through a great distribution experience. We’ll have Rust, TypeScript, and Ruby’s Pro companion packages available within the next couple of weeks.
The polyglot queue
Our thinly-veiled ambition is that River becomes the best way to plug and play a transactional job queue in every major language ecosystem. It uses your existing Postgres or SQLite database and runs natively in your language, so there’s no separate queue service or Go runtime to install. A quick pull from your package manager gets you started.
Years ago we considered multi-language support, but the development and maintenance just weren’t viable for a two-person team. Since late last year, the economics have flipped. We now think that not only can we maintain River in multiple languages, but maintain it well and with full feature parity while following each ecosystem’s conventions.
We hope you’ll try it! Take a look at our newly published Rust crate, NPM package, or Ruby gem.