Performance
Runjucks moves lexing, parsing, and rendering into Rust, then passes the result back to JavaScript. You still pay for JSON serialization of the context on each call and for the N-API boundary, so the wins show up most on CPU-heavy templates (large loops, many interpolations, deep attribute paths), not on tiny one-line snippets.
What the engine already does for you
Section titled “What the engine already does for you”- Parsed template caching — When you call
renderStringwith the same source string and compatible environment settings (delimiters,trimBlocks, registered extensions, etc.), the engine reuses the parsed AST instead of lexing and parsing again. - Named template caching — With
setTemplateMap, templates are cached by name when the map and parse settings are stable; repeatedrenderTemplate/getTemplatework benefits. Templateinstances —compile()/new Template()parse once per instance; reuse the instance across many.render(context)calls for the same file.- Rust-side hot paths — The core avoids unnecessary work where it can (for example, fast paths for common expression shapes and efficient context lookups). You do not need to configure these; they apply automatically.
Published vs Nunjucks snapshot
Section titled “Published vs Nunjucks snapshot”The table below is generated from a committed JSON report (docs/src/data/perf/reports/) keyed to the latest entry in index.json. It is not recomputed on every docs CI run.
How to read it: Each case is measured only after Runjucks and Nunjucks produce identical output for that template and context (same fairness setup as perf/run.mjs). nj / rj is mean Nunjucks latency divided by mean Runjucks latency for that case; > 1× means Nunjucks was slower on that row. Figures vary by CPU, OS, and Node version—use them as directional, not a guarantee for your hardware. For methodology and warm vs cold runs, see Measuring below.
Published benchmark snapshot
@zneep/runjucks0.1.8 vs nunjucks 3.2.4. Mode: warm ( one Environment per case; parsed templates cached ). Recorded 2026-03-29T16:31:02.613Z on darwin/arm64, v25.6.1.
Average nunjucks / runjucks (non-skipped cases): 3.86x — values greater than 1x mean Nunjucks was slower on average for this run.
Per-case mean latency (153 cases)
| Case | Runjucks (ms) | Nunjucks (ms) | nj / rj |
|---|---|---|---|
synth_plain_small | 0.0020 | 0.0030 | 1.48x |
synth_plain_large | 0.0112 | 0.1651 | 14.77x |
synth_many_vars | 0.0490 | 0.1265 | 2.58x |
synth_for_medium | 0.0999 | 0.0952 | 0.95x |
synth_filters_chain | 0.0025 | 0.0122 | 4.81x |
synth_var_trim_upper | 0.0028 | 0.0078 | 2.76x |
synth_var_trim_capitalize | 0.0029 | 0.0079 | 2.72x |
synth_if_nested | 0.0033 | 0.0165 | 5.06x |
synth_nested_for | 0.0068 | 0.0189 | 2.77x |
synth_long_var_lines | 0.0092 | 0.1411 | 15.34x |
synth_deep_if_chain | 0.0035 | 0.0153 | 4.33x |
synth_named_template_interp | 0.0031 | 0.0009 | 0.30x |
conf:smoke_plain_text | 0.0020 | 0.0030 | 1.49x |
conf:smoke_variable | 0.0025 | 0.0034 | 1.37x |
conf:tests_js_defined_false | 0.0021 | 0.0053 | 2.54x |
conf:tests_js_defined_true_null | 0.0028 | 0.0060 | 2.19x |
conf:tests_js_not_defined | 0.0027 | 0.0063 | 2.33x |
conf:tests_js_if_defined_branch | 0.0021 | 0.0120 | 5.64x |
conf:tests_js_if_defined_with_null | 0.0026 | 0.0120 | 4.63x |
conf:tests_js_null_is_null | 0.0020 | 0.0050 | 2.47x |
conf:tests_js_none_is_none | 0.0020 | 0.0050 | 2.47x |
conf:tests_js_falsy_zero | 0.0021 | 0.0056 | 2.71x |
conf:tests_js_truthy_pancakes | 0.0024 | 0.0060 | 2.50x |
conf:tests_js_addition_literal | 0.0020 | 0.0049 | 2.37x |
conf:tests_js_mul_literal | 0.0021 | 0.0048 | 2.35x |
conf:tests_js_filter_abs | 0.0021 | 0.0060 | 2.94x |
conf:tests_js_filter_capitalize | 0.0022 | 0.0057 | 2.61x |
conf:tests_js_filter_default_undefined | 0.0022 | 0.0074 | 3.30x |
conf:tests_js_for_batch | 0.0071 | 0.0334 | 4.68x |
conf:tests_js_set_and_output | 0.0024 | 0.0090 | 3.70x |
conf:tests_js_number_is_number | 0.0021 | 0.0050 | 2.33x |
conf:tests_js_string_is_string | 0.0021 | 0.0051 | 2.41x |
conf:tests_js_equalto | 0.0021 | 0.0072 | 3.46x |
conf:tests_js_sameas | 0.0034 | 0.0080 | 2.38x |
conf:tests_js_lower_is_lower | 0.0021 | 0.0052 | 2.45x |
conf:tests_js_upper_is_upper | 0.0021 | 0.0053 | 2.54x |
conf:lookup_nested_key | 0.0034 | 0.0060 | 1.78x |
conf:array_index_zero | 0.0031 | 0.0061 | 1.96x |
conf:filter_length_array | 0.0022 | 0.0085 | 3.92x |
conf:if_truthy_branch | 0.0026 | 0.0072 | 2.81x |
conf:for_simple_sum_style | 0.0041 | 0.0105 | 2.57x |
conf:expr_inline_if | 0.0021 | 0.0063 | 3.07x |
conf:jinja_compat_slice_range | 0.0035 | 0.0396 | 11.25x |
conf:jinja_compat_slice_step | 0.0035 | 0.0362 | 10.47x |
conf:globals_js_range_stop_join | 0.0023 | 0.0097 | 4.12x |
conf:globals_js_range_start_stop | 0.0025 | 0.0107 | 4.28x |
conf:globals_js_range_negative_step | 0.0025 | 0.0124 | 4.89x |
conf:globals_js_cycler_next | 0.0028 | 0.0187 | 6.67x |
conf:globals_js_joiner_alternate | 0.0029 | 0.0162 | 5.61x |
conf:globals_js_range_is_callable | 0.0021 | 0.0053 | 2.54x |
conf:globals_macro_is_callable | 0.0022 | 0.0093 | 4.18x |
conf:globals_shadow_range_context | 0.0025 | 0.0038 | 1.51x |
conf:env_add_global_callable_marker | 0.0021 | 0.0053 | 2.52x |
conf:regex_literal_test_substring | 0.0045 | 0.0118 | 2.64x |
conf:is_defined_missing_object_key | 0.0028 | 0.0072 | 2.56x |
conf:is_defined_array_oob | 0.0032 | 0.0142 | 4.40x |
conf:filters_not_callable_in_is_tests | 0.0023 | 0.0101 | 4.35x |
conf:macro_safe_output_not_autoescaped | 0.0038 | 0.0209 | 5.45x |
conf:tests_js_is_gt_ge_lt_le | 0.0027 | 0.0260 | 9.76x |
conf:tests_js_is_string_gt_coerce | 0.0021 | 0.0072 | 3.38x |
conf:tests_js_is_ne | 0.0023 | 0.0135 | 5.98x |
conf:tests_js_is_ne_no_arg | 0.0023 | 0.0124 | 5.43x |
conf:tests_js_is_undefined_unbound | 0.0022 | 0.0053 | 2.46x |
conf:tests_js_is_null_not_undefined | 0.0025 | 0.0054 | 2.18x |
conf:tests_js_is_escaped_safe | 0.0022 | 0.0083 | 3.78x |
conf:tests_js_is_not_escaped_raw | 0.0021 | 0.0052 | 2.50x |
conf:tests_js_is_eq_alias | 0.0027 | 0.0138 | 5.04x |
conf:tests_js_is_iterable_array_and_object | 0.0037 | 0.0108 | 2.93x |
conf:tests_js_is_iterable_string | 0.0025 | 0.0054 | 2.11x |
conf:tests_js_is_mapping | 0.0035 | 0.0108 | 3.06x |
conf:tests_js_same_var_sameas_object | 0.0032 | 0.0078 | 2.43x |
conf:tests_js_distinct_objects_equalto | 0.0040 | 0.0080 | 2.02x |
conf:filters_join_simple | 0.0024 | 0.0091 | 3.88x |
conf:filters_length_string | 0.0021 | 0.0058 | 2.78x |
conf:filters_upper | 0.0021 | 0.0056 | 2.66x |
conf:filters_replace | 0.0023 | 0.0083 | 3.58x |
conf:filters_replace_max_count | 0.0024 | 0.0093 | 3.98x |
conf:filters_replace_empty_needle | 0.0023 | 0.0083 | 3.63x |
conf:filters_random_singleton | 0.0021 | 0.0066 | 3.12x |
conf:filters_round | 0.0021 | 0.0061 | 2.95x |
conf:filters_escape_autoescape_off | 0.0022 | 0.0060 | 2.68x |
conf:filters_safe_with_autoescape | 0.0022 | 0.0075 | 3.48x |
conf:filters_default_boolean_true | 0.0021 | 0.0084 | 3.96x |
conf:filters_trim_title | 0.0025 | 0.0088 | 3.55x |
conf:filters_reverse_string | 0.0022 | 0.0058 | 2.72x |
conf:filters_first_last | 0.0023 | 0.0157 | 6.83x |
conf:filters_urlencode_string | 0.0022 | 0.0060 | 2.73x |
conf:filters_dictsort_keys | 0.0027 | 0.0185 | 6.77x |
conf:filters_select_odd | 0.0025 | 0.0174 | 6.97x |
conf:filters_reject_even | 0.0024 | 0.0152 | 6.39x |
conf:filters_abs_negative_int | 0.0020 | 0.0064 | 3.14x |
conf:filters_striptags_upstream_preserve | 0.0043 | 0.0096 | 2.25x |
conf:filters_striptags_upstream_flat | 0.0037 | 0.0072 | 1.96x |
conf:filters_length_object | 0.0033 | 0.0063 | 1.92x |
conf:filters_safe_then_escape | 0.0023 | 0.0080 | 3.49x |
conf:filters_escape_then_safe | 0.0024 | 0.0089 | 3.64x |
conf:filters_replace_regex_flags | 0.0076 | 0.0177 | 2.33x |
conf:filters_sum_array | 0.0022 | 0.0090 | 4.13x |
conf:tag_switch_basic | 0.0022 | 0.0106 | 4.72x |
conf:tag_for_kv_sorted | 0.0052 | 0.0216 | 4.14x |
conf:tag_multi_set | 0.0024 | 0.0092 | 3.80x |
conf:tag_set_block | 0.0035 | 0.0072 | 2.04x |
conf:tag_raw_braces | 0.0024 | 0.0041 | 1.69x |
conf:tag_verbatim_braces | 0.0024 | 0.0043 | 1.76x |
conf:tag_macro_greet | 0.0032 | 0.0164 | 5.13x |
conf:tag_macro_defaults_kwargs | 0.0046 | 0.0302 | 6.56x |
conf:tag_filter_block_upper | 0.0022 | 0.0084 | 3.89x |
conf:tag_switch_case1_only | 0.0022 | 0.0101 | 4.52x |
conf:tag_for_loop_builtins | 0.0042 | 0.0388 | 9.17x |
conf:tag_call_macro_caller | 0.0033 | 0.0248 | 7.52x |
conf:tag_call_caller_positional_args | 0.0060 | 0.0436 | 7.21x |
conf:tag_include_from_map | 0.0029 | 0.0062 | 2.10x |
conf:tag_include_nested_map | 0.0031 | 0.0066 | 2.11x |
conf:tag_include_inner_ignore_missing | 0.0024 | 0.0060 | 2.56x |
conf:tag_extends_layout_map | 0.0373 | 0.0165 | 0.44x |
conf:tag_trim_blocks_if | 0.0024 | 0.0078 | 3.23x |
conf:tag_trim_blocks_for | 0.0053 | 0.0156 | 2.94x |
conf:tag_lstrip_blocks_if | 0.0033 | 0.0113 | 3.46x |
conf:tag_trim_lstrip_combined | 0.0027 | 0.0091 | 3.33x |
conf:tag_trim_blocks_for_loop_items | 0.0073 | 0.0424 | 5.81x |
conf:tag_trim_blocks_no_var_strip | 0.0026 | 0.0057 | 2.16x |
conf:tag_custom_delimiters_var | 0.0029 | 0.0073 | 2.54x |
conf:tag_custom_delimiters_block | 0.0030 | 0.0110 | 3.68x |
conf:tag_custom_delimiters_for | 0.0050 | 0.0123 | 2.49x |
conf:tag_custom_delimiters_comment | 0.0022 | 0.0051 | 2.35x |
conf:tag_for_else_empty | 0.0028 | 0.0133 | 4.78x |
conf:tag_for_tuple_unpack | 0.0055 | 0.0217 | 3.97x |
conf:tag_for_loop_length | 0.0048 | 0.0147 | 3.06x |
conf:tag_for_loop_revindex | 0.0050 | 0.0148 | 2.98x |
conf:tag_for_loop_last | 0.0049 | 0.0195 | 3.99x |
conf:tag_set_frame_scoping | 0.0029 | 0.0176 | 6.14x |
conf:tag_nested_if | 0.0029 | 0.0178 | 6.18x |
conf:tag_include_ignore_missing | 0.0021 | 0.0075 | 3.57x |
conf:tag_extends_super | 0.0036 | 0.0688 | 19.07x |
conf:tag_macro_default_args | 0.0052 | 0.0368 | 7.15x |
conf:tag_import_macro_ns | 0.0035 | 0.0130 | 3.74x |
conf:tag_from_import_macro | 0.0036 | 0.0115 | 3.23x |
conf:tag_import_top_level_set | 0.0028 | 0.0101 | 3.65x |
conf:tag_import_with_context_macro | 0.0045 | 0.0131 | 2.93x |
conf:tag_import_without_context_macro | 0.0046 | 0.0124 | 2.70x |
conf:tag_from_import_set_and_macro | 0.0036 | 0.0133 | 3.64x |
conf:tag_import_macro_is_callable | 0.0028 | 0.0121 | 4.24x |
conf:tag_filter_block_lower | 0.0022 | 0.0086 | 3.95x |
conf:tag_inline_if_expr | 0.0026 | 0.0073 | 2.76x |
conf:tag_inline_if_false | 0.0026 | 0.0073 | 2.76x |
conf:tag_whitespace_trim_open_close | 0.0026 | 0.0065 | 2.53x |
conf:tag_comment_removed | 0.0020 | 0.0052 | 2.58x |
conf:tag_trim_blocks_comment_preserves_newline | 0.0020 | 0.0052 | 2.56x |
conf:tag_trim_blocks_endraw_preserves_newline | 0.0020 | 0.0056 | 2.76x |
conf:tag_import_multi_target_export | 0.0033 | 0.0151 | 4.63x |
conf:tag_import_block_set_export | 0.0031 | 0.0131 | 4.29x |
conf:tag_from_import_multi_and_block | 0.0035 | 0.0143 | 4.08x |
conf:tag_import_chained_top_level_sets | 0.0030 | 0.0101 | 3.40x |
Skipped cases
conf:tag_include_without_context_divergent— compareWithNunjucks false (runjucks-only golden; no nunjucks perf baseline)
Recommendations
Section titled “Recommendations”- Reuse environments and templates — Prefer keeping a long-lived
Environmentand reusingTemplateobjects instead of parsing the same string on every request. - Ship a release native addon in production — Build with
npm run build(release). Debug builds are much slower; see Development. - Keep contexts JSON-friendly — The context is converted to JSON-compatible values for the engine. Very large or deeply nested objects cost more to cross the boundary and to look up; structure data to match what templates actually read.
- Optional JSON-string ingress — If profiling shows that JSON serialization of the context is a bottleneck, you can pass a JSON string (
renderStringFromJson) instead of a live object so Rust parses the payload once. Advanced builds can enable a faster JSON parser in the native addon; seeRUNJUCKS_PERF.mdin the repository. - Compare fairly vs Nunjucks — Microbenchmarks differ by workload; Runjucks aims to improve worst-case CPU-heavy rows. Treat numbers from
npm run perfas directional, not a universal speedup factor.
Measuring
Section titled “Measuring”- Node vs Nunjucks — From the package root:
npm run perf(optionalnpm run perf:coldfor a cold Runjucks environment each iteration). Seeperf/README.mdin the repo.npm run perf:jsonwritesperf/last-run.jsonincludingrunjucksVersion,nunjucksVersion,mode(warm/cold), andplatformmetadata—copy intodocs/src/data/perf/reports/<version>.jsonwhen refreshing the site snapshot (RELEASING.md). - Large vs small context (Runjucks only) —
npm run perf:contextafternpm run buildcompares the same template with a minimal context and a large unused nested object, so you can see whether end-to-end time is sensitive to JSON marshalling. Documented inperf/README.md. - Rust-only render cost —
cargo bench -p runjucks_core --bench render_hotspotsfrom thenative/directory (ornpm run bench:rustfrom the package root). Steady-state runs mostly measure evaluation after the parse cache is warm. - Rust-only parse cost —
cargo bench -p runjucks_core --bench parse_hotspots(ornpm run bench:rust:parse) measures lex + parse (cold compilation) and includes a pair comparing full render vs render-only on a pre-parsed AST. Seeperf/README.md.
Maintainers and contributors: engineering notes, regression list, and backlog live in RUNJUCKS_PERF.md in the repository.