The most important thing to know is this: transient storage is not “storage that resets”. It is storage namespaced by address(this) for the duration of a transaction, and almost every surprising thing about it follows from those two facts.
EIP-1153 gave Solidity TSTORE/TLOAD and the transient data location. The pitch is compelling: a reentrancy guard that costs ~100 gas instead of ~5000, in-transaction accumulators that don’t need cleanup, callback context that vanishes when the call ends. All true. But the mental model most developers carry (“it’s like a local variable that’s visible across calls”) is wrong in ways that silently corrupt state, defeat try/catch, and break the exact reentrancy-guard pattern transient storage is marketed for.
Here is the short version. Transient storage:
- Is keyed on
(address, slot)per transaction, so underdelegatecallit writes the caller’s namespace, not the library’s. - Survives across committed call frames in a transaction, but is rolled back when the writing frame reverts, even if a
try/catchswallows that revert. - Reverts on
TSTOREinside astaticcall(with empty returndata), butTLOADsucceeds and reads the callee’s namespace. - Is restricted to value types; mappings and arrays must be hand-rolled in assembly, which is exactly where the hazards below live.
- Was, until recently, not plumbed through the optimizer at all, and shipped a wrong-slot
deletecorruption bug.
Let’s take them one at a time.
1. delegatecall writes the caller’s transient namespace
Transient storage is namespaced by address(this). Under delegatecall, address(this) is the caller. So a library or logic contract that does tstore(0, x) does not write to “its own” slot 0. It writes to the caller’s transient slot 0, which is whatever transient state variable the caller declared first.
contract Caller {
uint256 transient counter; // transient slot 0
function delegateWrite(address writer, uint256 v) external {
(bool ok, ) = writer.delegatecall(
abi.encodeWithSignature("writeSlot0(uint256)", v)
);
require(ok);
}
}
contract Writer {
function writeSlot0(uint256 v) external {
assembly { tstore(0, v) } // writes the delegatecaller's slot 0
}
}
After caller.delegateWrite(writer, 99), caller.counter == 99. The library silently clobbered the caller’s transient state.
This is the single most dangerous thing about transient storage, because transient is being actively promoted for the library reentrancy-guard pattern, the one context where delegatecall is the norm. A shared guard library that writes a low transient slot constant will collide with any caller that declares its own transient variables. The compiler does not auto-namespace high-level transient declarations; slot 0 is slot 0 for everyone.
The mitigation is to never write low constant slots from delegatecalled code. Derive slots with erc7201(...) (which the compiler exposes as a plain uint256 you can feed to tstore):
uint256 slot = erc7201("myproject.reentrancy.lock");
assembly { tstore(slot, value) } // collision-resistant in the caller's namespace
Audit rule: enumerate every contract’s deterministic transient slot allocation, and confirm any delegatecalled library uses keccak-derived slots, not constants.
2. staticcall: TSTORE reverts, TLOAD reads the callee
The static-context matrix is asymmetric and worth memorizing:
- Regular call: both
TSTOREandTLOADtarget the callee’s namespace (isolated). delegatecall: both target the caller’s namespace (§1).staticcall:TSTOREreverts (it’s a state write, banned by EIP-214) with empty returndata;TLOADsucceeds and reads the callee’s namespace.
The empty-returndata part matters for error handling. A TSTORE reverting under static context returns (false, ""): no Error, no Panic. You can only catch it with catch (bytes memory) or a bare catch {}, never a typed catch Error/catch Panic.
The TLOAD part trips people up the other way: a view function that reads another contract’s transient state via staticcall reads that contract’s namespace, not your own. There is no “borrow the caller’s transient for a read”; to see the caller’s transient state, the data must be passed explicitly.
3. try/catch does not preserve transient writes from a reverted frame
This is the finding that breaks the most mental models. Transient storage is shared across self-call frames within a transaction, but only across committed frames. If a sub-call writes transient state and then reverts, those writes are rolled back to zero, even when the caller swallows the revert with try/catch.
contract C {
uint256 transient lock;
function inner() external { lock = 1; revert("x"); }
function outer() external returns (uint256) {
try this.inner() {} catch {}
return lock; // == 0 (rolled back, NOT 1)
}
function innerOk() external { lock = 1; }
function outerOk() external returns (uint256) {
this.innerOk();
return lock; // == 1 (committed frame, visible)
}
}
try/catch governs control flow, not state rollback. The discriminator for whether a transient write survives is whether the writing frame committed or reverted, not whether anyone is in the same transaction, and not whether the revert was caught. This is silent: there is no diagnostic, and the behavior is identical on the legacy and via-IR backends because the EVM does the revert scoping, not the compiler.
If you build an in-transaction accumulator, a “work done” flag, or a reentrancy guard on transient storage, never rely on a try/catch-tolerated sub-call’s transient writes persisting. They won’t.
4. The public transient getter is a trap
You can declare uint256 public transient x; and it compiles with no warning. The auto-generated getter does a TLOAD. But transient storage is per-transaction, so:
- Within the same transaction, after
x = v, a cross-contract call tothis.x()returnsv. - Any off-chain
eth_call, or any later transaction, reads the zero default.
public and transient are semantically at odds: public implies “externally inspectable state”, transient implies “gone at end of tx”. The compiler does not warn about the combination. A frontend that reads contract.x() to display a value, or a bool public transient locked; guard you expect to inspect from another transaction, always sees zero.
5. Constructor writes persist into the deploying transaction
Because CREATE/CREATE2 fix the contract address before the init code runs, a transient variable written in the constructor lands in the (deployedAddress, tx) namespace, the same one runtime calls read. So constructor-seeded transient state is visible to any same-transaction call to the new contract, and zero in any later transaction.
contract Child {
uint256 transient seed;
constructor(uint256 v) { seed = v; }
function readSeed() external view returns (uint256) { return seed; }
}
contract Factory {
function deployThenRead(uint256 v) external returns (uint256) {
Child c = new Child(v);
return c.readSeed(); // == v in the deploying tx; 0 in any later tx
}
}
Don’t use transient as constructor scratch space in a contract that may be deployed inside an attacker-influenced transaction, since the residue is readable by any same-tx call.
6. The delete wrong-slot corruption (a shipped bug)
For a window of 0.8.x releases (roughly 0.8.28 through 0.8.33; fixed in 0.8.34, Feb 2026), a regular T x and a transient T y of the same type shared a single generated zeroing helper. The helper emitted whichever store opcode (SSTORE or TSTORE) was generated first. So delete y could compile to sstore(slotOf(y), 0), clearing the wrong slot entirely.
contract C {
uint x; // regular storage
uint transient y; // transient storage
function clearAll() public {
delete x; // generated the zeroing helper with SSTORE
delete y; // reused it -> ran SSTORE on y's slot (pre-0.8.34)
}
}
This is silent data corruption with no source-level signal. If you maintain contracts compiled with an affected version that use both regular and transient storage of the same type, the workaround on those versions is to assign = 0 instead of delete (the assignment path doesn’t use the buggy helper). On 0.8.34+ the change is invisible at the source level: recompile and it’s correct.
7. The composability warning is unreliable
Solidity emits Warning 2394 about transient-storage composability, but its coverage has two holes that matter for review:
- It fires only for assembly
tstore, not for high-leveltransientassignment.counter = von atransientstate variable (the idiomatic form) is completely silent, whileassembly { tstore(0, v) }warns. Same bytecode, opposite diagnostic. - It fires at most once per compilation. A second guard exists: after the first
tstoresite is warned, every subsequent site in the entire compilation unit is silenced. Two contracts with threetstorecalls between them produce one warning, and which site gets it depends on source order.
So you cannot use the presence (or count) of Warning 2394 as a proxy for “how much transient storage this codebase uses”. Tools that grep (2394) occurrences undercount. Every transient keyword is a review item regardless of whether the warning fired.
8. The optimizer barely knows transient exists
Transient storage was, until recently, not plumbed through the Yul optimizer:
- No load-forwarding.
tload(k)immediately aftertstore(k, v)is never replaced byv, because the optimizer’s store/load location enum only knows Memory and Storage. The byte-identicalsstore/sloadform collapses to pure arithmetic; the transient form keeps every opcode. - No dead-store elimination. A provably-dead
tstore(overwritten before any read) is preserved unconditionally; the equivalent deadsstoreis removed. - No redundant-store dedup.
tstore(0, 42); tstore(0, 42)emits twoTSTOREs on both backends.
None of this is a correctness problem; it’s a silent, linearly-scaling gas pessimization with no diagnostic. The practical takeaway: a “modernizing” refactor that swaps a storage reentrancy guard for a transient one can increase gas in transient-heavy code, and selecting --via-ir does not fix it. If you need the optimization, dedup by hand in assembly.
Conclusion
Transient storage is genuinely useful, but it is not a “free, self-cleaning local”. It is per-transaction, per-address state with revert semantics, and the compiler is quiet about almost all of the sharp edges. The two that cause real damage are §1 (delegatecall clobbering the caller’s slots, which breaks the very library guard pattern transient is sold for) and §3 (try/catch-swallowed reverts silently rolling back transient writes, defeating the “shared across the tx” model). The §6 delete bug is a genuine correctness window in shipped releases.
Audit checklist
- Does any delegatecalled library or logic contract write a constant transient slot? (Collision with caller’s namespace.)
- Are delegatecall transient writes routed through
erc7201(...)-derived slots? - Does any logic depend on transient writes from a
try/catch-tolerated sub-call surviving? (They don’t.) - Are
TSTORE-in-staticcallreverts handled withcatch (bytes)/ barecatch, not typed catches? - Is any
public transientvariable expected to be readable cross-transaction or off-chain? (Always zero.) - Was the contract compiled with a 0.8.28–0.8.33 release while mixing regular and transient storage of the same type? (
deletewrong-slot corruption.) - Is the presence/absence of Warning 2394 being used as evidence of transient usage? (Unreliable.)
- Did a storage-to-transient refactor measurably change gas in the wrong direction?