Performance
How chart scripts run, what the step budget measures, and the habits that keep heavy scripts responsive.
Most scripts are fast without any tuning. Write the clearest version first, check that it is correct, and only then look for repeated work that is worth removing. This page explains how scripts are executed on a chart, what the step budget measures, and the handful of habits that keep heavy scripts responsive.
How a chart runs a script
A run replays the chart’s loaded history through your handlers, bar by bar from the first bar to the live edge, and then lays the outputs out on the chart. Flowscope starts a new run when:
- the code or an input changes,
- bars are added (the chart loads more history or a new bar opens), or
- the forming bar changes and the script reads live values. Lightweight scripts refresh up to every 100 ms; expensive ones back off up to 750 ms based on measured computation time. Indicators with only close handlers reading local OHLCV, VD or CVD skip these intrabar replays. New bars and history changes still trigger a run.
Runs happen on the engine’s worker threads, not on the UI thread: the chart keeps showing the previous output until the new run is done, and a slow script cannot stall panning, the heatmap or the DOM. In the browser build, history replay runs in small cooperative steps on the page thread and yields between clock events, allowing market updates, input and painting between steps. A single handler is still atomic: an expensive loop in one handler can still stall a frame. The assistant’s script results report computation time, excluding the waits between steps.
Because every run replays the whole history, what counts is the work per bar times the number of loaded bars. A typical indicator costs a few microseconds per bar, so even tens of thousands of bars replay in a fraction of a second.
Stay inside the step budget
Every handler run may spend 10 million steps, and the whole replay of the loaded history 100 million (a quarter of that in the browser, where the replay shares the page’s thread). A step is one unit of evaluation work: an operator, a function call, a field read, a loop iteration. Built-ins that walk a collection (includes, sum, indexOf…) also pay one step for every 16 elements they look at. Ordinary handlers use a few hundred steps; the budgets only matter for loops, and especially nested loops.
If a handler exceeds a budget the script stops with a budget error in its status. Edit the script to reduce the work. The usual causes are:
| Cause | Fix |
|---|---|
| A loop over a long window on every bar | Use a ta.* function, or a rolling<T> with sum(), max(), sumLast(n) |
| Rebuilding a collection in every handler call | Keep it in state and update it with push, shift, set |
| Drawing current-only objects on every historical bar | Draw under chart.isLast in an update handler |
| Nested loops over levels and bars | Keep per-level results in a map and update only what changed |
Reuse work within a handler
When several outputs need the same value, compute it once and share the local:
script "Distance from VWAP"
data chart = subscribe(data.ohlcv)
pane dpane = pane(title: "Distance from VWAP, %", height: 0.2)
plot (
vwapLine = plot.line(title: "VWAP", color: color.amber, width: 2)
distance = plot.histogram(title: "Distance", on: dpane)
)
on chart.update {
let close = chart.close
let vwap = chart.vwap()
let pct: float? = close == null || vwap == null || vwap == 0.0 ? null : (close / vwap - 1.0) * 100.0
vwapLine.plot(vwap)
distance.plot(pct, color: (pct ?? 0.0) >= 0.0 ? color.green : color.red)
}Calling chart.vwap() twice would not only do the work twice; each call site keeps its own history, so you would also maintain two identical accumulators.
Prefer rolling functions
ta.* functions keep exactly the history they need and update it incrementally. Rebuilding the same window by hand on every bar is both slower and longer.
// slow: scans the whole window on every bar
state highs = rolling<float>(200)
on chart.close {
highs.push(chart.high)
let top = highs.reduce(0.0, |acc, v| math.max(acc, v ?? 0.0))
}// fast: incremental, and null-aware
on chart.close {
let top = ta.highest(chart.high, 200)
}When you do need your own window, rolling<T> with its built-in aggregates (sum, avg, min, max, stdev, sumLast) is the next best choice. Reach for map, filter and reduce when the transformation is genuinely custom, and keep the collections short.
Bound collections and drawing pools
Every collection has a capacity fixed when it is made, at most 50 000 elements. Choose capacities that match what the script uses or shows:
- A rolling window needs exactly its length:
rolling<float>(len). - A map of price levels needs as many entries as levels you display, not every level ever seen. Remove stale keys with
delete. - An array that is full stops the script on the next
push; checkisFullandshift()first.
Drawing pools have the max you give them, and a full pool recycles its oldest drawing. Treat keys as identities:
- Use a stable key for an object that represents the same thing over time, such as
"day-high". Eachget(key).set(...)moves the existing drawing instead of creating a new one. - For a stream of historical marks, use a counter or the bar’s time as the key and let the pool’s
maxdecide how many stay. - Call
pool.delete(key)when a drawing no longer means anything.
Subscribe only to what you use
Each subscription adds work to every run:
- Other timeframes are built from the chart’s bars, so they are cheap; they must be whole multiples of the chart’s interval.
- Trades are copied from the chart’s tape into each run and delivered print by print. Filter them at the source with
minSize:when you can, so the handler only sees the prints you care about. - Order books are read from the chart’s recording once per period the script asks about, and kept for the run.
- Volume profiles come from footprints; while a script reads them, the chart loads footprints for the bars on screen, which costs memory and network.
Do not add a subscription until the calculation reads it.
Draw current-only objects at the live edge
Objects that only describe “now”, such as today’s levels, a status card or a projection to the right of price, need not be drawn on every historical bar. Compute on every bar, draw only under isLast:
script "Live day levels"
data (
chart = subscribe(data.ohlcv)
daily = subscribe(data.ohlcv, timeframe: 1D)
stats = subscribe(data.stat)
)
state (
levels = entities.linePool(max: 2)
card = entities.labelPool(max: 1, anchor: anchor.topRight)
longs = 0.0
shorts = 0.0
previousTime: time? = null
)
on stats.close {
// Today's liquidations, restarting with each day.
let t = stats.time ?? time.fromUnixSeconds(0)
let newDay = time.isNewPeriod(t, previousTime, 1D)
previousTime = t
longs = (newDay ? 0.0 : longs) + (stats.sellLiq ?? 0.0)
shorts = (newDay ? 0.0 : shorts) + (stats.buyLiq ?? 0.0)
}
on chart.update {
let hi = daily.forming.high
let lo = daily.forming.low
let start = daily.forming.time
let t = chart.time
if chart.isLast && hi != null && lo != null && start != null && t != null {
levels.get("day-high").set(start, hi, t, hi, color: color.green, style: linestyle.dashed, extend: extend.right)
levels.get("day-low").set(start, lo, t, lo, color: color.red, style: linestyle.dashed, extend: extend.right)
let text = str.format("Range {0:.1}\nLongs liquidated {1:,.0}\nShorts liquidated {2:,.0}", hi - lo, longs, shorts)
card.get("today").set(12.0, 12.0, text)
}
}isLast is false throughout the history, so none of the drawing calls run there. Keep historical drawings, such as past swing labels, outside the condition if they should stay visible.
Put confirmed work in close handlers
An update handler runs on every change of the forming bar. Work that only needs the final value of a period, such as pushing into windows, counting events or updating per-period statistics, belongs in on x.close, which runs once per period. See repainting.
Keep handlers readable
Performance problems are easiest to find in handlers that are easy to read:
- Move configuration into
inputand derived constants intosetup, where they are computed once. - Name intermediate values with
letinstead of nesting long expressions. - Extract repeated logic into a function; remember that each call site of a function containing
ta.*keeps its own history.
Checklist
- Is each expensive value computed once per handler run?
- Are rolling questions answered with
ta.*orrolling<T>aggregates rather than loops? - Are collections in
state, bounded, and updated in place? - Do live-only drawings sit under
isLast, with stable keys? - Does every subscription earn its place?
See the limits table in the language reference for every hard limit in one place.