Performance characteristics of PhoenixFlags public API.

Run benchmarks yourself:

mix run bench/phoenix_flags_bench.exs

Benchmarks use their own phoenix_flags_bench database, created and migrated on first run. They deliberately do not share the test database: they run outside the Ecto sandbox, so their writes commit.

Environment

  • Elixir 1.20.0-rc.3 / OTP 27
  • PostgreSQL (local)
  • 8 flags across 4 categories (boolean, integer, string, decimal, percentage, select, and two variant flags)

I/O Profile

Each function's database and network call count per invocation:

FunctionDB CallsNetwork CallsNotes
get/2 (cached)00persistent_term.get + Map.get
get/2 (uncached)10SELECT ... WHERE key = $1
all_grouped/0 (cached)00persistent_term.get + sort/group in memory
all_grouped/0 (uncached)10SELECT all entries
flags/000Compiled module attribute
select_options/100flags/0 + list scan
variant/300persistent_term.get + SHA-256 + bucket walk
variants/100Compiled module attribute + list scan
get/3 with targeting00One extra persistent_term.get + rule walk
targets/110SELECT rules for one flag, with conditions
put_target/2, delete_target/12+NWrite + full cache reload, then peer notification
update_entry/32NSELECT by key + UPDATE (cache patched in-memory)

update_entry/3 also sends a fire-and-forget :reload message to Node.list() peers (N = number of connected nodes). Each peer then performs 1 DB call (SELECT all) to reload its local cache.

Results

1. Cached reads — get/2

Zero DB calls. Reads from :persistent_term + Map.get.

Scenarioipsavgmedian99th %memory
get/2 boolean7.61 M131 ns98 ns232 ns88 B
get/2 integer6.04 M166 ns122 ns318 ns88 B
get/2 string5.30 M189 ns130 ns347 ns88 B
get/2 decimal6.34 M158 ns107 ns277 ns88 B
get/2 percentage6.29 M159 ns117 ns302 ns88 B
get/2 select6.15 M162 ns124 ns315 ns88 B
get/2 missing key6.44 M155 ns120 ns294 ns88 B

All types perform identically at ~100-130 ns median. The value is already cast and stored in a map — get/2 does no type conversion at read time.

2. all_grouped/0 (cached)

Zero DB calls. Reads entries from :persistent_term, sorts by pre-computed declaration order, groups by category.

Scenarioipsavgmedian99th %memory
all_grouped/0713 K1.40 us1.25 us4.46 us1.73 KB

The flag ordering index is pre-computed once at startup and stored in persistent_term, avoiding per-call flags() |> Enum.with_index() overhead.

3. flags/0 (compiled)

Zero DB calls. Returns a compiled list of %PhoenixFlags.Flag{} structs.

Scenarioipsavgmedian99th %memory
flags/028.96 M35 ns31 ns65 ns96 B

Fastest function — returns a module attribute.

4. select_options/1

Zero DB calls. Scans the flags/0 list for a matching key.

Scenarioipsavgmedian99th %memory
select_options/1 (select)5.11 M196 ns145 ns342 ns120 B
select_options/1 (non-select)9.00 M111 ns63 ns152 ns120 B

4b. variant/3 — A/B assignment

Zero DB calls. The split is parsed once when the cache loads, so a call is a :persistent_term read, one SHA-256, and a walk of the cumulative bucket list.

Measured separately from the sections above, on Elixir 1.20.3 / OTP 29 on a loaded development machine. Figures are rounded and should be read as orders of magnitude — repeat runs varied by up to 3x on the same code, and Benchee reports deviations above 1000% at this timescale. The call-count column in the I/O profile above is the part that is exact.

Scenarioipsmediannotes
variants/1 (declaration lookup)~5 M~0.16 uscompiled attribute + list scan
variant/3 (ttl: nil)~1.5 M~0.55 usone SHA-256
variant/3 (ttl: 24h)~1.0 M~0.90 usa second hash for the per-identity offset, plus a clock read
variant/3 (telemetry: true)~0.8 M~0.90 usadds :telemetry.execute/3

variant/3 is roughly 5-7x the cost of get/2, which is the SHA-256. That is deliberate: :erlang.phash2/2 would be cheaper but is not guaranteed stable across OTP major versions, and an OTP upgrade silently reshuffling every live experiment is a far worse outcome than ~0.5 us per assignment. At ~1.5 M assignments/sec it is not a bottleneck for request-path use.

Setting ttl: roughly doubles the cost, so leave it nil (the default) unless you actually want assignments to expire.

4c. Targeting overhead

Zero DB calls. Resolution is ordered so that the overwhelmingly common case is cheapest: a flag with no rules costs one :persistent_term read plus a map lookup, and never touches the process dictionary. The context is only fetched once a rule exists that could match.

That ordering was chosen by measurement, not intuition. Checking the context first — to skip the :persistent_term read entirely when none is set — turned out to be slower: Process.get/1 plus a Keyword.get/3 measured ~32 ns against the ~18 ns read it was avoiding. Reversing it halved the context-set-but-no-rules path.

Measured in isolation against a no-op floor, same process:

Operationmedian above floor
One :persistent_term.get/2~18 ns
Keyword.get + Process.get + map_size~32 ns

So the added cost for a flag with no rules is roughly one persistent_term read plus a map lookup — on the order of 20 ns against a get/2 in the low hundreds of nanoseconds.

End-to-end medians for the four scenarios are not quoted here: the two "flag has no rules" cases provably perform identical work (the context is never read), yet repeat runs on a loaded machine differ by 2x with Benchee reporting deviations above 4000%. At this timescale only the isolated figures above are meaningful. bench/phoenix_flags_bench.exs section 4c runs all four in one process if you want to see them on your own hardware.

Rule evaluation when a context is set adds the context normalisation (one pass, to_string/1 per attribute) and a walk of that flag's rules until one matches — still no database call, and rules per flag are typically a handful.

5. update_entry/3 (DB write + incremental cache patch)

2 DB calls per update: SELECT by key + UPDATE. Cache is patched in-memory (no reload query).

Scenarioipsavgmedian99th %memory
update_entry/3 boolean (x2)5281.89 ms1.85 ms2.61 ms1.08 KB
update_entry/3 string (x2)5271.90 ms1.86 ms2.62 ms1.08 KB

Each benchmark iteration performs 2 updates (toggle + restore), so a single update_entry/3 takes ~0.95 ms. The cost is dominated by DB round-trips (SELECT + UPDATE). The cache reload SELECT ALL was eliminated by patching the persistent_term tuple in-memory.

6. Cache vs DB: get/2

Head-to-head comparison of cached (persistent_term) vs uncached (direct DB query) reads.

Scenarioipsavgmedian99th %memory
get/2 cached7.06 M0.14 us0.098 us0.25 us88 B
get/2 uncached (DB)16.3 K61 us53 us149 us13.9 KB

Cached reads are ~500x faster and use ~160x less memory than direct DB queries.

7. Cache vs DB: all_grouped/0

Scenarioipsavgmedian99th %memory
all_grouped/0 cached723 K1.38 us1.18 us3.72 us1.75 KB
all_grouped/0 uncached (DB)14.6 K69 us63 us117 us29.2 KB

Cached is ~50x faster and uses ~17x less memory.

8. Full workflow cycle

Simulates a dashboard interaction: 6 reads + grouped view + update + read + restore.

Scenarioipsavgmedian99th %memory
Full cycle (cached)3193.13 ms2.33 ms5.95 ms4.01 KB

Of the total time, the 8 cached reads contribute <2 us combined. The remaining time is entirely from the 2 update_entry/3 DB round-trips (4 DB calls total).

Summary

OperationLatencyDB CallsBottleneck
Read (cached)~100 ns0None (in-memory)
Read (uncached)~60 us1DB round-trip
Grouped view (cached)~1.3 us0Sort + group
Grouped view (uncached)~63 us1DB round-trip
Write~0.95 ms2DB round-trips (SELECT + UPDATE)
Flag metadata~30-150 ns0None (compiled)

Reads are zero-cost in production. Writes are intentionally slow (database is source of truth) and only happen during admin configuration changes.