Opening a project froze a construction site app for more than ten seconds. Not slow. Frozen. Taps did nothing, typing did nothing, and then everything the user had pressed happened at once.
The three fixes people reach for first do not help. This repo lets you prove that yourself. Load the project and watch a counter stop dead next to a spinner that keeps turning. Then switch on all three fixes and load it again. The freeze does not move.
Three commands. You need Python 3.12 or newer and nothing else. No accounts, no keys, no node, no npm, no build step.
git clone https://github.com/Zetesis-dev/jsi-thread-lock
cd jsi-thread-lock
python run.py
That sets up a local environment, generates the demo data into web/generated/, starts the page and
opens it at http://127.0.0.1:8002. Press Ctrl+C to stop it. On macOS and Linux, type python3
instead of python. Add --rebuild to start over from scratch.
Pick how the project loads, press the button, and press the number buttons while it runs.
Everything lands in a .venv folder. Activate it to reach the terminal version and the tests:
.venv\Scripts\activate # Windows
source .venv/bin/activate # macOS and Linux
python demo/before.py # one long stretch of work -> THREAD HELD
python demo/after.py # the same work, in slices -> THREAD FREE
pytest # the proof
python web/serve.py # the page again, skipping the setup step
demo/before.py and demo/after.py are the same file apart from one line:
$ diff demo/before.py demo/after.py
11c11
< STRATEGY = strategies.parse_blocking
---
> STRATEGY = strategies.parse_chunked
Same data, same work, same target. The only thing that changed is whether the work ever stops to let anything else happen.
Measured in Chromium on the laptop this was built on, at the middle data size of 500 inspections and 24,000 answers:
| How the project loads | Longest freeze | Work done | Redraws missed |
|---|---|---|---|
| All at once | 1.1 s, in one stretch | 1.1 s | ~65 |
| All at once, with all three fixes on | 1.1 s, in one stretch | 1.1 s | ~65 |
| In small pieces | ~10 ms at worst, across ~126 pieces | 1.1 s | 0 |
| Handed to a helper | never froze | 1.1 s | 0 |
The second row is the point of the whole repo. All three fixes are switched on, all three are really implemented, and all three demonstrably do their job. The row cache reports 250 reused rows on a second load and the drawing time falls. The number that matters does not change at all.
The last row is the other half of it. The work did not get any quicker. It moved to a helper running alongside, where it no longer keeps anyone waiting, and only short labels came back.
Presses are timed by the browser at the moment your finger goes down, not when the app gets round to noticing. In one scripted run a press landing 413 ms into the freeze waited 711 ms to be handled. The three presses after the freeze ended waited between 1 and 3 ms.
All three change how the screen is drawn. None of them change the unpacking that happens first. By the time any of them could run, the app has already spent the whole second and the user has already given up. The diagram at the top shows this as a shaded box: every suggested fix lives inside it, and the freeze happens to the left of it.
This is not an argument that the fixes are bad. Reusing rows, waiting for animations and keeping data in one place are all worth doing. They are answers to a different question.
tests/test_thread_is_held.py shows the blocking version does its work in one unbroken stretch, with
no pause anywhere inside it, lasting well past the point a person would notice. Not lots of small
delays. One long one.
tests/test_frame_budget_is_never_exceeded.py shows that no single slice of the chunked version runs
longer than 16.7 ms, the limit for motion that still looks smooth. That is what the fix actually buys.
Not a quicker parse, a parse that always stops in time. Slices are cut by the clock rather than by a
fixed count, so the guarantee still holds on a slow machine.
tests/test_frontend_fixes_do_not_help.py is the important one. It runs the three suggested fixes
against the blocking path and checks that the longest freeze is unchanged. It also checks each fix is
doing something real: the row cache is inspected for entries, the shared store is inspected for a write,
and the deferral is timed to confirm the delay happened. A fix that did nothing would prove nothing.
This runs in a browser, not in React Native. The browser has the same shape of problem: one lane doing all the work, and one way out of it. A Web Worker stands in for a background thread on a phone. No part of this talks to a real device.
The data is kept small on purpose, 1.55 MB in the middle size and 4.65 MB at the largest. Handing a
visitor's browser tens of megabytes to chew through is a memory risk, not a demonstration. The freeze
comes from the effort spent on each answer instead, set by WORK_SCALE in web/modes/jsi.js, because a
laptop is much quicker than the mid range site hardware where the original bug turned up. That number
sets how long the freeze lasts. It cannot change the shape of it, which is one stretch of work with no
break inside, and the shape is the only thing being argued about here. Lower it and every way of loading
gets quicker together, while the gap between them stays exactly where it is.
The freeze itself is real. The counter is driven by the browser's own redraw callback with no code anywhere that fakes a pause, and the timeline can only draw the red bar once the work is over, because the page cannot redraw itself while it is frozen either.
This reconstructs a pattern from a real piece of work. The project, the drawings, the sheets, the inspectors, the areas and every checklist answer were invented for the reconstruction. None of it is the original code, and nothing here identifies the client or the system it came from.
The engineering writeup this goes with is at zetesis.dev.
MIT. See LICENSE.