Viscosity
Review
Part VII · Building Viscosity · Day 25

100. What Viscosity refuses to touch

The most common thing Viscosity says is no. Every no comes with a reason, and every reason is a small lesson in how a program can notice where its data lives.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Define a bailout, and tell a safety bailout apart from a decision that a change wouldn't pay off.
  • Explain, for each refusal in the differential corpus, what the program could observe if Viscosity went ahead.
  • Change a refused program so that Viscosity can prove it safe, without changing what it prints.
  • Contrast Viscosity's compile-time bailouts with an engine's run-time deoptimization.

Saying no

Viscosity’s differential corpus is a folder of small programs, each run twice, once as written and once compiled, and the two outputs must match. Each file’s first line, such as // viscosity: ps=aos, also says which layout Viscosity must pick. Nearly half of them are there to make Viscosity refuse. A wrong refusal costs some speed. A wrong acceptance changes what a program prints. So the refusals are tested as carefully as the transformations.

Intuition

A bank teller who can’t verify a signature doesn’t cash the cheque. It isn’t an accusation: the cheque may be perfectly good. The teller just can’t prove it, and the cost of being wrong falls on someone else.

Definition

A bailout is leaving a collection unchanged because its safety can’t be proved. In Viscosity it is a reason string in the collection’s bailouts list, and viscosity explain prints it after unsafe:. A bailout says nothing is known to be wrong; only that something might be.

Step through six refusals from the corpus. Each traces a chain of uses from the collection to the first use Viscosity can’t rewrite:

Step throughA tour of refusals
A chain of uses from a collection to the use Viscosity cannot rewriteCORPUS: closure_alias.jspscollectionps[3]index.get → elementconst p = ps[3]← storeBinding() => p.xnot followedunsafe: line 4: an element is stored in anothervariable (p)
The element gets a second name, p, and a closure keeps it. After a rewrite there is no object for p to hold, so f() could no longer see the loop’s += 1. Viscosity stops at the store; it doesn’t follow p.
1/6

The rest of this chapter takes them one lesson at a time, with the real explain output (wrapped to fit).

Lesson 1: two names, one object

From aliasing_identity.js:

const keep = [];
for (let i = 0; i < ps.length; i++)
  keep.push(ps[i]);
keep[0].x = 42;
console.log(ps[0].x, keep[0] === ps[0]);
ps (line 2) {x} → kept as array of
  objects
  · unsafe: line 7: an element is
    compared or combined with "==="
  · unsafe: line 5: an element is used
    by push

It prints 42 true: one object, two names (chapter 4). In a struct of arrays an element is a row number, not an object. To give keep an element, Viscosity would have to build one, and a built object is a copy. Writes to it are lost, and === compares identity, so a fresh copy is never the same object[1]:

Try itWhat columns would have to fake
// Columns, and a way to build an
// element object on demand.
const soa = { x: [0, 1, 2] };
const get = (i) => ({ x: soa.x[i] });

const keep = [get(0)];
keep[0].x = 42;
console.log(soa.x[0]);
console.log(keep[0] === get(0));
// With real objects these would
// print 42 and true.

Chapter 106 looks at what it would take to keep identity anyway. Here, Viscosity’s answer is to refuse.

Lesson 2: a name that outlives the line

From closure_alias.js:

const p = ps[3];
const f = () => p.x;
for (let i = 0; i < ps.length; i++)
  ps[i].x += 1;
console.log(f());
· unsafe: line 4: an element is stored
  in another variable (p)

f() must print 4, the value after the loop. Only a real object shared by p and ps[3] gives that. Viscosity doesn’t try to follow p into the closure; storing an element under another name is enough to stop it. There is an easy fix, though: keep the index instead of the element. Try it: replace the first two lines with const k = 3; and const f = () => ps[k].x;.

PlaygroundFix the refusal

Lesson 3: code that can’t be seen

From escapes_log.js and map_method.js:

// console.log(ps[3]);
· unsafe: line 5: an element is passed
  to a method call

// ps.map((p) => p.x).slice(0, 3)
· unsafe: line 5: uses .map on the
  array (only .length and .push are
  supported)
· unsafe: the array is passed to a
  method call at line 5

console.log prints the object, { x: 4, y: 3 }, keys and all. map calls a function with every element and collects the results. Neither can be rewritten into columns without building objects, and in general a function Viscosity can’t see into might do anything with what it’s given. Unknown code counts as code that observes.

Lesson 4: shapes, names and types

Four more refusals come from facts the earlier chapters established:

// shape_mismatch.js (chapter 97)
· unsafe: line 4: records with
  different fields or field order
  ({x, y} vs {y, x})

// record_shared.js (chapter 97)
· unsafe: line 6: the new record is also
  referenced elsewhere, so it could be
  aliased

// reassigned.js: later, ps = [];
· unsafe: "ps" is reassigned (line 5),
  so it may not always hold this array

// string_index.js: const k = '5';
· unsafe: line 6: indexes the array with
  something that may not be a number

reassigned.js is the plainest. After ps = [], the name means a different array, one built after Viscosity’s rewrite was planned; every ps[i] would have to work on both. And ps[k] with a string happens to work for '5', but the same line with 'length' reads a property, so a string can’t be trusted as an index.

Lesson 5: callers it can’t see

// interproc_mixed.js: total(a), total(b)
· unsafe: line 10: passed to total(),
  which is also called with other values

// param_unknown.js:
// export function nudge(dx) {...}
ps (line 2) {x} → kept as array of
  objects
  · no field is provably numeric, so
    there are no typed columns to gain

The second one is not a bailout at all; notice there is no unsafe:. nudge is exported, so its callers aren’t all visible and dx might be anything. ps[i].x += dx could then put a string in x, so x gets no typed column, and with no typed column there is nothing to gain. The same goes for all_generic.js, whose only field is a string, and for no_loops.js and small_auto.js: safe, but not worth changing. Those are the cost model’s decisions, in the next chapter.

Under the hood

Engines bail out too, but later. V8’s optimizing tiers speculate from the feedback they’ve seen and, when a guess fails at run time, deoptimize: they throw the optimized code away and carry on in a slower tier[2]. A bailout there costs time at the moment it happens. Viscosity’s bailouts happen before the program runs and can’t be undone, so they last for the whole run. In exchange, the decision is written down, with a reason you can read and act on.

Your turn

ExercisePrint the refusal

A collection is { name, line, fields, bailouts }. Write refusal(c), returning the lines viscosity explain prints when it bails out, as an array of strings: first NAME (line L) {F1, F2} → kept as array of objects, then one line ' · unsafe: ' + reason per reason, in order, without duplicates. If there are no bailouts, return null (the collection is safe, and other questions decide its layout).

function refusal(c) {
  // your code here
  return null;
}

console.log(refusal({
  name: 'ps', line: 2, fields: ['x'],
  bailouts: ['"ps" is reassigned'],
}));
Extra practiceWhich lesson is it?

Write lesson(reason) that sorts a real bailout reason into a lesson. Return 'identity' if it mentions "===" or stored in another variable; 'unknown code' if it mentions method call, function call or on the array; 'shape' if it mentions field order or referenced elsewhere; 'type' if it mentions may not be a number; otherwise 'other'. Check them in that order.

function lesson(reason) {
  // your code here
}

console.log(lesson('line 6: indexes ' +
  'the array with something that ' +
  'may not be a number'));

What you learned

  • A bailout leaves a collection unchanged because safety can’t be proved. explain prints each one after unsafe:.
  • Identity: a second name (another array, a variable, a closure) or === needs real element objects, which columns don’t have.
  • Unknown code: calls and array methods that Viscosity can’t see into might observe the elements, so they count as observing.
  • Shapes, names, types: mismatched or shared literals, a reassigned binding, and indexes that may not be numbers.
  • Callers: a known function must get the same collection at every call. Unknown callers can also make columns non-numeric, which is a cost decision, not a bailout.

Check your understanding

0/6
  1. 1.

    explain says ps … → kept as array of objects · no loop reads or writes its fields. Is this a bailout?

  2. 2.

    Why does keep.push(ps[i]) make ps unsafe?

  3. 3.

    Which change lets Viscosity accept closure_alias.js, with the same output?

  4. 4.

    Why is ps.map(f) a bailout even if f only reads p.x?

  5. 5.

    A program has let ps = [], fills it, and later does ps = []. Why does Viscosity refuse?

  6. 6.

    How do Viscosity’s bailouts differ from V8’s deoptimizations?

References

  1. [1] MDN contributors. Equality comparisons and sameness. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness (checked 2026-10-31)
  2. [2] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)