RustyJson

A JSON library for Elixir powered by Rust NIFs, designed as a drop-in replacement for Jason.

Why RustyJson?

The Problem: JSON encoding in Elixir can be memory-intensive. When encoding large data structures, Jason (and other pure-Elixir encoders) create many intermediate binary allocations that pressure the garbage collector. For high-throughput applications processing large JSON payloads, this memory overhead becomes significant.

Why not existing Rust JSON NIFs? After OTP 24, Erlang's binary handling improved significantly, closing the performance gap between NIFs and pure-Elixir implementations. Libraries like jiffy and the original jsonrs struggled to outperform Jason on modern BEAM versions. Additionally, the original jsonrs is incompatible with Rustler 0.37+, which is required by many other packages.

RustyJson's approach: Rather than trying to beat Jason on speed alone, RustyJson focuses on:

  1. Dramatically lower memory usage during encoding (10-20x reduction for large payloads)
  2. Competitive encoding speed (3-6x faster for medium/large data)
  3. Full Jason API compatibility as a true drop-in replacement
  4. Modern Rustler 0.37+ support for compatibility with the ecosystem

Installation

def deps do
[{:rustyjson, "~> 0.1"}]
end

Pre-built binaries are provided via Rustler Precompiled. To build from source, set FORCE_RUSTYJSON_BUILD=true.

Drop-in Jason Replacement

RustyJson implements the same API as Jason:

# These work identically to Jason
RustyJson.encode(term) # => {:ok, json} | {:error, reason}
RustyJson.encode!(term) # => json | raises
RustyJson.decode(json) # => {:ok, term} | {:error, reason}
RustyJson.decode!(json) # => term | raises
# Phoenix interface
RustyJson.encode_to_iodata(term)
RustyJson.encode_to_iodata!(term)
# Options match Jason
RustyJson.encode!(data, pretty: true)
RustyJson.decode!(json, keys: :atoms)

Phoenix Integration

# config/config.exs
config :phoenix, :json_library, RustyJson

Migrating from Jason

Find/replace JasonRustyJson in your codebase:

# Before
@derive {Jason.Encoder, only: [:name, :email]}
Jason.encode!(data)
Jason.Fragment.new(json)
# After
@derive {RustyJson.Encoder, only: [:name, :email]}
RustyJson.encode!(data)
RustyJson.Fragment.new(json)

Fragments

Inject pre-encoded JSON directly:

fragment = RustyJson.Fragment.new(~s({"pre":"encoded"}))
RustyJson.encode!(%{data: fragment})
# => {"data":{"pre":"encoded"}}

Formatter

Pretty-print or minify JSON strings:

RustyJson.Formatter.pretty_print(json_string)
RustyJson.Formatter.minify(json_string)

Benchmarks

All benchmarks run on Apple Silicon M1. Results may vary on other architectures.

Synthetic Benchmarks

PayloadEncodingDecoding
Small (~25 bytes)~1x1.3x faster
Medium (~7 KB)3-6x faster2x faster
Large (~500 KB)3-4x faster1.2x faster

Note: Small payloads show minimal difference due to NIF call overhead.

Real-World Benchmark: Amazon Settlement Reports

Processing 31 settlement reports (TSV → parsed data → JSON files) with reports containing 4 to 15,820 rows each:

Example: 13,073-row report (2.1 MB download)

MetricJasonRustyJsonImprovement
Save JSON time1,556 ms70 ms22x faster
Memory (Save JSON)+146.8 MB+6.7 MB22x less
Total memory+162.3 MB+22.4 MB7x less

Example: 10,961-row report (1.82 MB download)

MetricJasonRustyJsonImprovement
Save JSON time1,317 ms51 ms26x faster
Memory (Save JSON)+149.0 MB+16 KB9,300x less
Total memory+161.9 MB+21.9 MB7x less

The memory difference is most dramatic during the encoding step itself, where RustyJson avoids the intermediate allocations that Jason requires.

Features

Built-in Type Support

These types are handled natively in Rust without protocol overhead:

TypeJSON Output
DateTime"2024-01-15T14:30:00Z"
NaiveDateTime"2024-01-15T14:30:00"
Date"2024-01-15"
Time"14:30:00"
Decimal"123.45"
URI"https://example.com"
MapSet[1, 2, 3]
Range{"first": 1, "last": 10}
StructsObject without __struct__
TuplesArrays

Options

Encoding:

Decoding:

Custom Encoding

For custom types, implement the RustyJson.Encoder protocol and use protocol: true:

defimpl RustyJson.Encoder, for: Money do
def encode(%Money{amount: amount, currency: currency}) do
%{amount: Decimal.to_string(amount), currency: currency}
end
end
RustyJson.encode!(money, protocol: true)

Or use @derive:

defmodule User do
@derive {RustyJson.Encoder, only: [:name, :email]}
defstruct [:name, :email, :password_hash]
end

JSON Spec Compliance

RustyJson passes 72+ tests covering full JSON spec compliance:

How It Works

Why RustyJson Is Different

Most Rust JSON libraries for Elixir use serde to convert between Rust and Erlang types. This requires:

  1. Erlang term → Rust struct (allocation)
  2. Rust struct → JSON bytes (allocation)
  3. JSON bytes → Erlang binary (allocation)

RustyJson eliminates the middle step by walking the Erlang term tree directly and writing JSON bytes without intermediate Rust structures.

Key Optimizations

Custom Direct Encoder:

Custom Direct Decoder:

Memory Allocator: Uses mimalloc by default. Alternatives available via Cargo features:

[features]
default = ["mimalloc"]
# Or: "jemalloc", "snmalloc"

What We Learned

The bottleneck for JSON NIFs isn't parsing or formatting—it's crossing the NIF boundary and building Erlang terms. SIMD-accelerated parsers like simd-json and sonic-rs showed minimal improvement because term construction dominates the workload.

The wins come from:

  1. Avoiding intermediate allocations (no Rust structs, no serde)
  2. Efficient term building (direct writes to Erlang heap)
  3. Good memory allocator (mimalloc reduces fragmentation)

Limitations

Acknowledgments

License

MIT License