Zero-Copy Anchor Accounts for Large On-Chain Orderbooks
Hold multi-kilobyte orderbooks on-chain without stack overflows using anchor zero copy accounts, bytemuck casting, and safe realloc patterns for Solana programs.
A 10,000-order on-chain orderbook needs roughly 640 KB of account data if each slot is 64 bytes. Try to deserialize that with Anchor's default Account<'info, T> and your program panics before it does anything useful — Borsh copies the entire buffer onto the BPF stack, and the SBF VM gives you a hard 4 KB stack frame. You don't get a graceful error. You get Access violation in stack frame and a transaction that burns fees for nothing. #[account(zero_copy)] exists precisely for this: it hands you a reference into the raw account bytes instead of a heap-allocated copy, so a 640 KB struct costs you a pointer, not 640 KB of stack.
Why the default deserializer falls over
When you write pub book: Account<'info, OrderBook>, Anchor calls OrderBook::try_deserialize, which runs Borsh over the full account data and materializes an owned OrderBook value on the stack. For a struct with a handful of u64s, fine. For a fixed array of ten thousand order slots, that owned value is enormous, and it has to live somewhere the VM can't accommodate.
The heap doesn't save you either. Solana programs get a 32 KB bump allocator by default — one allocation, no free. A Box<OrderBook> moves the copy off the stack but immediately blows past the heap ceiling. People discover this the hard way after refactoring a working program to add one more market and watching it start failing at deploy-time simulation.
Zero-copy sidesteps the copy entirely. The data stays in the account buffer that the runtime already mapped into your program's address space, and you operate on it through a Ref or RefMut.
The layout: bytemuck does the heavy lifting
#[account(zero_copy)] requires your struct to be Pod (plain old data) and Zeroable, which Anchor derives via bytemuck. That imposes real constraints, and they're the whole point:
- Every field must itself be
Pod. NoVec, noString, noOption, no enums with data. Fixed arrays only. - The struct must be
#[repr(C)]so the compiler doesn't reorder fields. - No padding bytes may be uninitialized, which in practice means you manually pad to alignment.
Here's a book that holds up in production:
use anchor_lang::prelude::*;
#[account(zero_copy)]
#[repr(C)]
pub struct OrderBook {
pub market: Pubkey, // 32
pub seq: u64, // 8
pub bid_count: u32, // 4
pub ask_count: u32, // 4
pub bids: [Order; 10_000], // 640_000
pub asks: [Order; 10_000], // 640_000
}
#[zero_copy]
#[repr(C)]
pub struct Order {
pub owner: Pubkey, // 32
pub price: u64, // 8
pub size: u64, // 8
pub order_id: u64, // 8
pub ts: i64, // 8
// 64 bytes, naturally 8-aligned, no padding needed
}
Notice Order is exactly 64 bytes with no trailing pad. That's deliberate. bytemuck will reject a cast if the byte length isn't an exact multiple of the type size, and misaligned or padding-bearing structs are where the confusing PodCastError panics come from. Lay fields out largest-alignment-first and the compiler won't insert gaps. When in doubt, assert_eq!(std::mem::size_of::<Order>(), 64) in a unit test so a field addition can never silently shift your on-disk layout.
The alignment rule that bites people: the account data buffer starts at an 8-byte offset after Anchor's discriminator. Anchor handles this for you, but if you ever cast raw bytes yourself with bytemuck::from_bytes, you must account for that 8-byte discriminator prefix or every field reads garbage. Getting PDA and account layout right across a program is its own discipline — the same care I described in the piece on designing PDA seeds for multi-vault trading programs applies directly to how you version zero-copy state.
Loading and mutating without a copy
Zero-copy accounts don't give you a plain reference. You get a Loader, and you explicitly borrow:
pub fn place_order(ctx: Context<PlaceOrder>, price: u64, size: u64) -> Result<()> {
let mut book = ctx.accounts.book.load_mut()?;
let idx = book.bid_count as usize;
require!(idx < book.bids.len(), BookError::Full);
book.bids[idx] = Order {
owner: ctx.accounts.trader.key(),
price,
size,
order_id: book.seq,
ts: Clock::get()?.unix_timestamp,
};
book.bid_count += 1;
book.seq += 1;
Ok(())
}
load_mut() returns a RefMut backed by a RefCell, so the usual borrow rules apply at runtime. Hold a load() and a load_mut() on the same account in one instruction and you'll hit a AccountBorrowFailed panic. The fix is to scope your borrows tightly — drop one before taking the next, or restructure so you only borrow once. On initialization you must call load_init() exactly once, not load_mut(), or Anchor's discriminator check rejects the freshly created account.
Because you're writing directly into mapped account memory, mutations are visible the instant you assign — there's no serialize-back step at the end of the instruction. That also means a mid-instruction panic can leave partial writes, so validate before you mutate, not after.
Growing the account with realloc
Solana caps a single account at 10 MB, and any account created in one instruction can only be up to 10 KB at creation. That 640 KB book has to be grown incrementally. The pattern is realloc, and it comes with a per-instruction growth cap of 10,240 bytes (MAX_PERMITTED_DATA_INCREASE). You cannot jump from 8 KB to 640 KB in one call. You issue a sequence of grow instructions, each adding at most 10 KB, topping up rent-exempt lamports as the size climbs.
#[derive(Accounts)]
pub struct GrowBook<'info> {
#[account(
mut,
realloc = book.to_account_info().data_len() + 10_240,
realloc::payer = payer,
realloc::zero = false,
)]
pub book: AccountLoader<'info, OrderBook>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
Set realloc::zero = false when you're appending capacity you'll initialize yourself; leave it true only if you rely on the new region being zeroed, since zeroing large regions burns compute you may not have. Track a capacity field in the struct header so instruction logic knows how many slots are actually usable versus reserved, and gate order placement on it. A subtle gotcha: after a realloc grows the buffer, an AccountLoader you loaded earlier in the same transaction may point at a stale mapping. Reload after growth.
For anything holding real user funds, the layout-versioning story matters as much as the mechanics — I'd never ship a zero-copy state migration without an independent audit of the account model, because a single miscounted padding byte silently corrupts every downstream read.
When zero-copy is the wrong tool
It isn't free. You lose Borsh's convenience, you can't nest dynamic types, and client-side you have to decode the raw layout by hand or with a matching TypeScript struct definition. If your state is a few hundred bytes, the default Account<T> is simpler and the copy is trivial. Zero-copy earns its complexity once you're past roughly 4 KB, or once you're storing fixed-size collections that would otherwise force heap allocation. Orderbooks, large position registries, and per-market fill queues are the textbook cases — the same structures that show up when you start batching state reads with address lookup tables for multi-leg transactions.
One more practical note: keep your reads compute-cheap. Scanning a 10,000-slot array linearly on every match will exhaust your compute budget fast, so maintain sorted insertion or a price-indexed side structure inside the same account. The memory layout and the access pattern are one design problem, not two.
If you're building an on-chain matching engine or a large stateful Solana program and want the account model designed correctly from the first commit, our smart contract development team does exactly this work.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article