We tried to find the heap a Keycloak upgrade needs per million users. It does not exist.
Upgrading 26.0.0 → 26.7.1 on Postgres 16.15, we walked -Xmx down against two realms, one with 1,002 users and one with 2,000,003. The boundary landed in the same place for both: 160 MB works, 128 MB dies with OutOfMemoryError. Not "roughly the same". The same bracket, and the same 16–17 seconds to ready on either side of a 2,000× difference in user count.
The reason is in the log. At 128 MB the last thing Keycloak prints is:
Updating the server image Terminating due to java.lang.OutOfMemoryError: Java heap space
Updating the server image is Quarkus augmentation - the build step Keycloak re-runs on start whenever it detects a configuration change. It happens before a single row of the database is read, which is why the number is identical at 1k and 2M, and why no failed run touched the schema (changelog stayed at 144, every user intact).
Two floors, not one
That was on the stock image. We then ran the same bisect on a pre-built (--optimized) image, which skips augmentation entirely:
-Xmx | Stock image | Pre-built image |
|---|---|---|
| 160m | READY | READY |
| 128m | FAILED | READY |
| 96m | - | READY |
| 64m | - | FAILED |
The floor moves from (128m, 160m] to (64m, 96m]. Quote both numbers, and say which image the customer runs - the difference is the build step.
The same run explains a quieter thing. The pre-built image upgraded in 11s against the stock image's 17s, and the whole saving is the augmentation that never appears in Keycloak's own log. A large part of what this lab has been publishing as "time to ready" includes a build step a production deployment may not run at all.
Two practical consequences
First, if your Keycloak dies on startup with a heap error during an upgrade, stop looking at your user count. Read the last log line: Updating the server image means augmentation and points at the build step; Updating database would point at the migration. They have different fixes, and only one of them has anything to do with the dataset.
Second, the failure is not fast. Both of our OOM runs took just over four minutes to give up, thrashing the collector at the ceiling. If your window budget assumes a heap error announces itself immediately, it does not - budget four minutes for one to declare itself.
What we did not test:
- Custom provider jars. They are installed by the same phase that runs out of memory here, and could plausibly raise the floor.
- The floor with an expensive migration. This pair skips its index build above 300,000 rows.
- The optimized floor at 2M users.
- The steady-state serving floor, which is the one that matters most.
Everything above is a startup number. Run production on a number measured while serving.
Keycloak Advisory Watch - one email per advisory batch, within 72 hours of publication, listing the patched versions per maintained minor line. Double opt-in, no tracking pixels, no click tracking, public archive. Subscribe.
Self-hosted Keycloak is one of the things we keep patched, upgraded and owned for teams that run it but have nobody to run it. Talk to us about your project.
Sources: 2026-08-25-s5-jvm-heap-floor, 2026-08-26-runB-optimized-image, 2026-08-25-index-skip-threshold.