Upgrading
Performance Notes for 2.x Upgraders
If you ported an app from Wheels 2.x and your test suite or request times got noticeably slower on 4.0.3–4.0.5, this page is for you. A user report of exactly that (#3213) led to a profiling campaign, a root cause, and a fix. Everything below is a measured number from that work — no projections.
The reported regression
Section titled “The reported regression”The reporter ran the same RocketUnit test suite against both versions:
| Version | Suite duration |
|---|---|
| Wheels 2.5 | 274 s |
| Wheels 4.0.3 | 1,599 s (~27 min) |
That’s roughly a 6× slowdown on a like-for-like runner (their environment: Lucee 5 on Tomcat). Your mileage will differ by engine and workload, but the mechanism behind it applies to every 4.0.x install before the fix.
Root cause: per-instance mixin re-integration
Section titled “Root cause: per-instance mixin re-integration”The cost was not per-test or per-request — it was per object materialization. On 4.0.x before the fix, every model("X").new() and every row returned by findAll() / findOne() / findByKey() re-ran the framework’s mixin integration from scratch:
- a directory listing of
vendor/wheels/model/ - a
createObject()and agetMetaData()call per file in it (18 files) - a re-resolve of all ~231 mixed-in public methods, each with an override check
Controller and Mapper creation carried the identical pattern. A test suite (or any finder-heavy request) materializes huge numbers of objects, so the overhead compounded badly.
The fix (#3236)
Section titled “The fix (#3236)”The integration plan is now built once per application and replayed cheaply for every subsequent instance: the directory scan, per-file metadata, resolved method references, and the plugin-override set are all cached in application scope (a sibling of the schema cache). The cache is rebuilt on ?reload=true, so framework and plugin edits are still picked up in development. Semantics are unchanged — the same public methods and super<name> aliases are mixed in, in the same order, and a guard spec pins the behavior.
Measured on Lucee 7 + SQLite:
| Before | After | |
|---|---|---|
2,000 × model().new() | 2,772 ms | 1,513 ms (~1.8×) |
| Full framework test suite | 33.3 s | ~22 s |
The framework suite is only partly instance-creation, so its delta understates the win for an instance-heavy app suite like the one in the original report.
Where 4.0 stands after the fix
Section titled “Where 4.0 stands after the fix”Independent profiling of the framework in June 2026 (JFR + Apache Bench, Lucee 7 + SQLite) established the wider baseline:
- Cold first request ≈ 1.2–1.3 s, and ~85% of it is the Lucee CFML-to-bytecode compiler, not Wheels bootstrap logic (which measured ≈ 20 ms). Cold-start time is a compile cost you pay once per deploy — see the warm-up notes in the deployment guides.
- Warm request serving ≈ 0.38 ms in-JVM (~2,600 req/s single-instance, debug output off).
- The development debug bar costs ~34% of throughput (+0.13 ms/request) and inflates response size. It is strictly development-only — production requests never pay it — but keep it in mind when benchmarking in the
developmentenvironment: measure with debug output off, or inproductionmode.
The practical takeaway for benchmarking your upgraded app: warm the app first (the cold request is dominated by compilation), run in production/testing mode or with debug off, and compare per-suite or per-request timings before and after applying the fix below.
How to get the fix
Section titled “How to get the fix”All of this work is in stable releases — no channel switch is needed:
- 4.0.6 — the per-application mixin-plan cache described above (#3236).
- 4.1.0 — the model mixin surface moved to compile-time includes, so instances inherit the full model API instead of copying ~270 method references each (#3462), and model instances are created with a direct constructor call instead of the DI container’s resolve/auto-wire path plus a reflective invoke (#3464). Together with request-scope caching of the integration plan (#3461), this took 3,000 ×
model().new()from ~1,290 ms on 4.0.3 to ~508 ms on the #3213 benchmark rig (Lucee 7, warmed), narrowing the gap to 2.5 on that workload from ~4.9× to ~1.9×. Model overrides keep working unchanged; Upgrading Without the CLI lists the delegation forms (superDelete(),variables.superdelete(),super.delete()). - 4.1.1 — flash locking is scoped to where the data lives: session-stored flash takes a per-session lock instead of one application-wide lock, and cookie-stored flash is no longer locked at all (#3656, by @zspitzer). Under a 128-user load test this lifted throughput 10–14%.
Upgrade the CLI through your package manager, then swap the framework into your app:
wheels upgrade check # preview what would changewheels upgrade apply # swap your app's vendor/wheels to the CLI's bundled frameworkwheels server startwheels upgrade apply swaps vendor/wheels/ in place — do it on a branch or a copy of the app so it’s easy to roll back.