Docs

Performance & the Main Thread

An automation that works can still be slow in a way that shows: the page stutters while it runs, scrolling goes stiff, and a script that should take a moment takes several seconds. Almost always the cause is the same one thing — everything inside tab.evaluate runs on the page’s own main thread. This page covers the four patterns that avoid it. It assumes you’ve written a script already; if not, start with Writing Automations.

Dev preview — not stable. Tranquil is in early, active development. Everything here — APIs, interfaces, and behavior — is provisional and will change before a stable release, often without notice or backward compatibility. Don’t build anything you depend on against it.

What runs where

Your script and the page run in two different places, and the difference decides everything else:

  • The script itself — everything outside tab.evaluate — runs in a separate Deno process. Loops, parsing, fetch, file writing. None of it touches the browser.
  • Everything inside tab.evaluate runs on the page’s main thread: the single thread the page uses for JavaScript, style calculation, layout, paint and input handling. One thread, one task at a time, and nothing can interrupt a task in flight.

That second point has a consequence people miss. While your callback runs, the page cannot repaint or accept input — and your automation is blocked too, because evaluate doesn’t resolve until the page’s main thread is free again. A slow evaluate is slow twice.

For scale: a 60Hz display gives about 16.6ms per frame, and roughly 10ms of that is usefully yours once the browser has taken its share. Anything holding the thread past 50ms is long enough to be felt as a stutter.

Two operations inside evaluate are more expensive than they look:

  • Reading a layout value forces the browser to compute layout right then, so it has an answer for you: getBoundingClientRect(), offsetWidth/offsetHeight/offsetTop, clientWidth/ clientHeight, scrollWidth/scrollHeight/scrollTop, getComputedStyle(), and window.scrollX/scrollY.
  • Writing to the DOM invalidates that layout — inserting or removing a node, or setting a style that affects geometry.

Do one and then the other repeatedly and every read pays for a fresh layout of the whole document.

Batch reads before writes

This is the one rule worth memorising. If a script measures the page and then changes it, do all the measuring first and all the changing second.

The shape to avoid — a read and a write alternating inside a loop:

// 🔴 One full layout PER IMAGE
await tab.evaluate(() => {
  for (const img of document.querySelectorAll("img")) {
    const rect = img.getBoundingClientRect();    // read  — needs layout
    const tag = document.createElement("span");
    tag.textContent = `${rect.width}×${rect.height}`;
    document.body.appendChild(tag);              // write — invalidates it again
  }
});

Split it into two passes and the whole run costs one layout, however many images there are:

// 🟢 All reads, then all writes
await tab.evaluate(() => {
  // Pass 1 — measure. scrollY is the same for every image, so read it once, not per loop.
  const scrollY = window.scrollY;
  const plans = [];
  for (const img of document.querySelectorAll("img")) {
    const rect = img.getBoundingClientRect();
    plans.push({ top: rect.top + scrollY, left: rect.left, text: `${rect.width}×${rect.height}` });
  }

  // Pass 2 — place. Nothing here reads geometry, so it all settles in one layout.
  const frag = document.createDocumentFragment();
  for (const plan of plans) {
    const tag = document.createElement("span");
    tag.textContent = plan.text;
    tag.style.cssText = `position:absolute;top:${plan.top}px;left:${plan.left}px`;
    frag.appendChild(tag);
  }
  document.body.appendChild(frag);
});

Hoisting scrollY matters for the same reason as the split: it is a layout read like any other, and its answer can’t change while the loop runs.

The measure-and-place example is this pattern end to end, with the wrong version kept in a comment for contrast.

Do the work in the script, not in the page

Splitting a long task into chunks is the usual web answer to a busy main thread. In an automation there’s a better one available: your script is already running somewhere else. Pull the data out in one evaluate, do the thinking in the script, and push the result back.

// 🔴 Parsing, filtering and sorting thousands of rows on the page's thread
const top = await tab.evaluate(() => {
  const parse = (tr: Element) => ({
    name: tr.querySelector("td")?.textContent ?? "",
    score: Number(tr.querySelector("[data-score]")?.textContent ?? 0),
  });
  return [...document.querySelectorAll("tr")]
    .map(parse)
    .filter((r) => r.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 20);
});
// 🟢 The page hands over raw values; the script does the rest in its own process
const rows = await tab.evaluate(() =>
  [...document.querySelectorAll("tr")].map((tr) => ({
    name: tr.querySelector("td")?.textContent ?? "",
    score: Number(tr.querySelector("[data-score]")?.textContent ?? 0),
  }))
);
const top = rows
  .filter((r) => r.score > 0)
  .sort((a, b) => b.score - a.score)
  .slice(0, 20);

The page only does what only the page can do — reach the DOM.

Cross the boundary as rarely as you can

Each evaluate is a separate round trip to the browser, so a call per element is slow even when each call is trivial. Return an array from one call instead of making one call per item:

// 🔴 One round trip per link
const links = [];
for (let i = 0; i < count; i++) {
  links.push(await tab.evaluate((n: number) => document.links[n].href, i));
}
// 🟢 One round trip
const links = await tab.evaluate(() => [...document.links].map((a) => a.href));

Two things about that boundary are worth knowing before you design around it. Arguments and return values cross as JSON, so send what you need rather than the whole page. And evaluate sends your callback to the page as source, which means it cannot close over anything in your script — a helper defined outside it is not in scope when it runs. Define helpers inside the callback, or pass what they need as arguments:

const prefix = "item-";

// 🔴 `prefix` is not defined in the page — this throws
await tab.evaluate(() => document.querySelectorAll(`[id^="${prefix}"]`).length);

// 🟢 Passed across the boundary as an argument
await tab.evaluate((p: string) => document.querySelectorAll(`[id^="${p}"]`).length, prefix);

Wait for conditions, not for time

A fixed sleep is a guess, and it is wrong in both directions: too short on a slow connection, where your script quietly scrapes a page that hadn’t finished loading, and wasted time on every fast run. tab.waitFor polls for the thing you actually need.

import { delay } from "jsr:@std/async@1/delay";

// 🔴 Guessing
await tab.evaluate(() => document.querySelector<HTMLButtonElement>("#more")?.click());
await delay(1500);
// 🟢 Waiting for the result
await tab.evaluate(() => document.querySelector<HTMLButtonElement>("#more")?.click());
await tab.waitFor(".result-row:nth-child(20)", { timeout: 10000 });

waitFor takes either a CSS selector or a page predicate, and defaults to an 8-second timeout polling every 200ms. It throws when the timeout expires, so wrap it in try/catch where carrying on with partial results is better than failing the run.

One limitation shapes how you use it: a predicate function receives no arguments, unlike evaluate. When a condition depends on a value your script holds, mark the DOM first and then wait on a plain selector:

// Tag what's already there, so "new" becomes something a selector can express
await tab.evaluate(() => {
  document.querySelectorAll(".result").forEach((el) => el.setAttribute("data-seen", ""));
  document.querySelector("#more")?.click();
});
await tab.waitFor(".result:not([data-seen])");

search-to-bookmarks uses exactly this to page through results.

Quick reference

The recurring mistakes, and what to reach for instead. All four cost you either a frozen page or a slower run, and none of them are obvious from reading the script back.

Anti-patternInstead
Reading geometry and writing to the DOM in the same loopMeasure everything, then apply everything
Re-reading scrollY, or any unchanging value, inside a loopHoist it above the loop
Sorting, parsing or filtering large data inside evaluateReturn raw values; compute in the script
One evaluate per elementOne evaluate returning an array
await delay(ms) after an actiontab.waitFor(selector)
A predicate that needs a value from your scriptMark the DOM, then wait on a selector

Two smaller ones worth knowing. Background tabs are capped at about four concurrent, so a fan-out wider than that queues rather than failing — bound your own concurrency to match (see fetch-titles). And ui.notify puts a notification on screen, so calling it inside a tight loop costs real work per call; report progress in steps, not per item.


Next: Engine — the headless, scheduled kind of automation.