Skip to content

perf(image): move image pre-rendering off the main thread#24

Merged
bahdotsh merged 2 commits into
mainfrom
feat/background-image-prerender
Mar 18, 2026
Merged

perf(image): move image pre-rendering off the main thread#24
bahdotsh merged 2 commits into
mainfrom
feat/background-image-prerender

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

@bahdotsh bahdotsh commented Mar 18, 2026

Summary

  • Move all expensive image work (Lanczos3 resize, PNG/Sixel/base64 encode) to background threads — the main thread never touches image pixels, eliminating UI hangs during image loading
  • Replace rebuild() with lightweight finalize_layout() when image fetches complete — adjusts placeholder rows and rebuilds indices without re-parsing the entire markdown document
  • Store raw images as Arc<DynamicImage> to avoid cloning large pixel buffers when handing off to pre-render threads

Details

The old code fetched images in the background but ran pre_render() synchronously on the main thread. For large images this meant hundreds of milliseconds of Lanczos3 resizing and protocol-specific encoding blocking the event loop. It also called rebuild() (full markdown re-parse) on every single image completion.

Now there are two background channels:

  1. Fetch channel (existing) — downloads/decodes raw images
  2. Render channel (new) — resizes and encodes for the target protocol (Kitty/iTerm2/Sixel/HalfBlock)

Results from stale terminal widths or cancelled file switches are automatically discarded. The viewer shows a "Loading" placeholder until both fetch and pre-render complete.

Test plan

  • cargo build — compiles cleanly
  • cargo test — all 120 tests pass
  • cargo clippy — no warnings
  • Manual: open a markdown file with multiple remote images, verify smooth scrolling during load
  • Manual: resize terminal while images are loading, verify images re-render at correct size
  • Manual: switch files while images are loading, verify no stale images appear

It turns out that the reason image loading was causing scroll
hangs and general UI misery is that we were doing *all* the
expensive image work on the main thread. Fetching happened in
the background (good), but as soon as the raw image arrived,
pre_render() would run synchronously — Lanczos3 resizing, PNG
encoding, Sixel quantization, base64 encoding — right there in
the event loop. Blocking everything.

This is not great.

The old code also called rebuild() on every single image
completion, which re-parses the entire markdown document just
to adjust a few placeholder rows. For a document with ten
images, that's ten full re-parses for absolutely no reason.

The fix has two parts:

1. Replace the synchronous pre_render() with background threads.
   queue_all_pre_renders() spawns a thread per image that does
   the heavy resize/encode work off the main thread. Results
   come back via a dedicated channel (poll_pre_rendered()),
   separate from the fetch channel. Images are stored as
   Arc<DynamicImage> so we hand off a refcount bump instead of
   cloning potentially 16MB of pixel data.

2. Extract finalize_layout() from rebuild(). When a raw image
   fetch completes, the event loop now calls finalize_layout()
   instead of rebuild() — it adjusts placeholder rows and
   rebuilds indices without re-parsing markdown. The main
   thread never touches image pixels.

Stale pre-render results (from terminal resizes or file
switches) are discarded via content_width checks and channel
replacement, same pattern we already use for fetch cancellation.
The pre-render pipeline was happily spawning a new thread for every
single image with *no* concurrency limit. The fetch side already had
MAX_CONCURRENT_FETCHES = 10, but apparently nobody thought the
pre-render side deserved the same courtesy. A document with 30
images would spawn 30 Lanczos3 resize threads simultaneously.
That's not parallelism, that's a denial of service attack on your
own CPU.

While at it, poll_pre_rendered() was setting `any = true` even when
the pre-render result was None (i.e., the background thread
panicked). This triggered a pointless re-render cycle for an image
that will never be ready. Move the flag inside the Some(data) arm
where it belongs.
@bahdotsh bahdotsh merged commit b457edb into main Mar 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant