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.evaluateruns 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(), andwindow.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-pattern | Instead |
|---|---|
| Reading geometry and writing to the DOM in the same loop | Measure everything, then apply everything |
Re-reading scrollY, or any unchanging value, inside a loop | Hoist it above the loop |
Sorting, parsing or filtering large data inside evaluate | Return raw values; compute in the script |
One evaluate per element | One evaluate returning an array |
await delay(ms) after an action | tab.waitFor(selector) |
| A predicate that needs a value from your script | Mark 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.