Optimizing Block Size / Gas Limit

The Throughput vs. Decentralization Trade-off

Every blockchain protocol designer eventually runs into the same wall: making blocks bigger increases transaction throughput, but it also increases the time it takes for a newly mined or proposed block to propagate across the peer-to-peer network. That propagation delay does two bad things — it raises the probability that two blocks are produced almost simultaneously (an orphan/stale block), and it quietly filters out validators who don’t have the bandwidth or hardware to keep up, pushing the network toward a smaller set of well-resourced “professional” nodes. This is the throughput-vs-decentralization trade-off that sits at the heart of every gas limit governance debate — Bitcoin’s block size wars, Ethereum’s periodic gas limit votes, and every high-throughput L1/L2 design decision since.

In this article we build a concrete, numerically solvable optimization model of this trade-off, find the optimal gas limit for different governance preferences, and visualize the entire trade-off surface.

Formalizing the problem

Let $G$ denote the gas limit (our proxy for block size), $T$ the fixed block interval, and let each network node $i$ have bandwidth $b_i$. We convert gas into raw bytes using an average bytes-per-gas ratio $\beta$, so a block’s size in bytes is $G\beta$.

Propagation time. Using a Decker–Wattenhofer-style flooding model, the time for node $i$ to receive and relay a block of gas limit $G$ is:

$$
\tau_i(G) = \tau_0 + \frac{G\beta}{b_i \cdot \eta}
$$

where $\tau_0$ is a fixed base latency (hashing, signature checks, handshake) and $\eta \in (0,1]$ is a protocol efficiency factor (compact block relay, erasure coding, etc.).

Orphan / stale rate. Using the classical Bitcoin propagation-race result, if the typical (median-bandwidth) node’s propagation time is $\bar\tau(G)$, the probability that a competing block appears before it fully propagates is:

$$
p_{\text{orphan}}(G) = 1 - e^{-\bar\tau(G)/T}
$$

Decentralization fraction. A node is considered a “healthy” full validator if it can propagate and verify the block within a safety margin $f$ of the block interval, i.e. $\tau_i(G) \le fT$. Given a bandwidth distribution $b_i \sim \text{LogNormal}(\mu,\sigma)$ across the network, the decentralization fraction is the empirical fraction of nodes that survive:

$$
D(G) = \Pr\big[\tau_i(G) \le fT\big]
$$

Effective throughput. Only non-orphaned blocks contribute useful throughput:

$$
\text{TPS}(G) = \frac{G}{g_{\text{tx}}\cdot T}\Big(1 - p_{\text{orphan}}(G)\Big)
$$

where $g_{\text{tx}}$ is the average gas cost per transaction.

Composite objective. A governance body (or protocol designer) chooses $G$ to maximize a weighted objective that trades throughput against decentralization loss, with $\lambda \ge 0$ representing how strongly the community values decentralization:

$$
U(G,\lambda) = \text{TPS}(G) - \lambda \cdot \text{TPS}_{\text{ref}}\cdot\big(1 - D(G)\big)
$$

Sweeping $\lambda$ traces out a full Pareto frontier of optimal gas limits, from the purely throughput-maximizing choice ($\lambda = 0$) to increasingly conservative, decentralization-preserving choices.

Python implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy.optimize import minimize_scalar, differential_evolution

plt.style.use('dark_background')
matplotlib.rcParams['figure.facecolor'] = '#0d1117'
matplotlib.rcParams['axes.facecolor'] = '#0d1117'
matplotlib.rcParams['savefig.facecolor'] = '#0d1117'
matplotlib.rcParams['axes.edgecolor'] = '#444444'
matplotlib.rcParams['grid.color'] = '#333333'
matplotlib.rcParams['font.size'] = 11

BYTES_PER_GAS = 0.0035
MEDIAN_BW_MBPS = 25.0
MEDIAN_BW = MEDIAN_BW_MBPS * 1e6 / 8.0
SIGMA_LOG = 0.7
TAU_BASE = 0.1
ETA = 0.7
T_BLOCK = 12.0
F_MARGIN = 0.5
AVG_GAS_PER_TX = 60000.0
N_NODES = 6000

rng = np.random.default_rng(42)
bandwidths = rng.lognormal(mean=np.log(MEDIAN_BW), sigma=SIGMA_LOG, size=N_NODES)

def prop_time(G, bw):
G_arr = np.asarray(G, dtype=float)
G_bytes = G_arr * BYTES_PER_GAS
return TAU_BASE + G_bytes / (bw * ETA)

def decentralization_fraction(G):
tau = prop_time(G, bandwidths)
return float(np.mean(tau <= F_MARGIN * T_BLOCK))

def orphan_rate(G):
tau_med = prop_time(G, MEDIAN_BW)
return 1.0 - np.exp(-tau_med / T_BLOCK)

def throughput_tps(G):
p = orphan_rate(G)
G_arr = np.asarray(G, dtype=float)
return (G_arr / AVG_GAS_PER_TX) / T_BLOCK * (1.0 - p)

G_MIN, G_MAX = 1.0e7, 1.5e10
G_grid = np.linspace(G_MIN, G_MAX, 300)

TPS_grid = throughput_tps(G_grid)
TPS_REF = float(TPS_grid.max())

tau_matrix = prop_time(G_grid[None, :], bandwidths[:, None])
D_grid = np.mean(tau_matrix <= F_MARGIN * T_BLOCK, axis=0)
orphan_grid = orphan_rate(G_grid)

def utility(G, lam):
return float(throughput_tps(G)) - lam * TPS_REF * (1.0 - decentralization_fraction(G))

lambda_grid = np.linspace(0.0, 6.0, 50)

G_star_list = []
TPS_star_list = []
D_star_list = []
orphan_star_list = []
U_star_list = []

for lam in lambda_grid:
res = minimize_scalar(lambda G: -utility(G, lam), bounds=(G_MIN, G_MAX),
method='bounded', options={'xatol': 1e3})
G_star = res.x
G_star_list.append(G_star)
TPS_star_list.append(float(throughput_tps(G_star)))
D_star_list.append(decentralization_fraction(G_star))
orphan_star_list.append(float(orphan_rate(G_star)))
U_star_list.append(-res.fun)

G_star_arr = np.array(G_star_list)
TPS_star_arr = np.array(TPS_star_list)
D_star_arr = np.array(D_star_list)
orphan_star_arr = np.array(orphan_star_list)
U_star_arr = np.array(U_star_list)

LAMBDA_CHECK = 1.0
grid_check = minimize_scalar(lambda G: -utility(G, LAMBDA_CHECK), bounds=(G_MIN, G_MAX), method='bounded')
de_check = differential_evolution(lambda x: -utility(x[0], LAMBDA_CHECK), bounds=[(G_MIN, G_MAX)],
seed=42, maxiter=300, tol=1e-12, polish=True)

print("=" * 70)
print("Global optimizer cross-check at lambda = 1.0")
print("=" * 70)
print(f"Bounded local search : G* = {grid_check.x/1e9:.4f} billion gas | U = {-grid_check.fun:.2f}")
print(f"Differential Evolution: G* = {de_check.x[0]/1e9:.4f} billion gas | U = {-de_check.fun:.2f}")
print()

print("=" * 70)
print("Pareto-optimal gas limits under different decentralization weights")
print("=" * 70)
print(f"{'lambda':>8} | {'G* [B gas]':>12} | {'Block size [MB]':>16} | {'TPS':>8} | {'Orphan %':>9} | {'D(G*) %':>8}")
for lam_show in [0.0, 0.5, 1.0, 2.0, 3.0, 5.0]:
res = minimize_scalar(lambda G: -utility(G, lam_show), bounds=(G_MIN, G_MAX), method='bounded')
Gs = res.x
size_mb = Gs * BYTES_PER_GAS / 1e6
tps = throughput_tps(Gs)
orp = orphan_rate(Gs) * 100
dec = decentralization_fraction(Gs) * 100
print(f"{lam_show:8.1f} | {Gs/1e9:12.3f} | {size_mb:16.2f} | {tps:8.1f} | {orp:9.2f} | {dec:8.2f}")
print()

peak_idx = int(np.argmax(TPS_grid))
print(f"Pure throughput-maximizing gas limit (lambda=0 reference): {G_grid[peak_idx]/1e9:.3f} billion gas")
print(f" -> at this point decentralization fraction D(G) = {D_grid[peak_idx]*100:.2f}%")

fig1, ax1 = plt.subplots(figsize=(11, 6))
l1, = ax1.plot(G_grid/1e9, TPS_grid, color='#00d4ff', lw=2.6, label='Effective throughput (TPS)')
ax1.set_xlabel('Gas limit G [billion gas]')
ax1.set_ylabel('Throughput [tx/s]', color='#00d4ff')
ax1.tick_params(axis='y', labelcolor='#00d4ff')
ax1.grid(alpha=0.25)

ax2 = ax1.twinx()
l2, = ax2.plot(G_grid/1e9, orphan_grid*100, color='#ff6b6b', lw=2.2, ls='--', label='Orphan / stale rate [%]')
l3, = ax2.plot(G_grid/1e9, D_grid*100, color='#6bcb77', lw=2.2, ls='-.', label='Decentralization fraction D(G) [%]')
ax2.set_ylabel('Probability / Fraction [%]')

ax1.axvline(G_grid[peak_idx]/1e9, color='#ffd93d', lw=1.4, ls=':')
ax1.set_title('Throughput vs. Orphan Risk vs. Decentralization as a function of Gas Limit')

lines = [l1, l2, l3]
ax1.legend(lines, [ln.get_label() for ln in lines], loc='upper left', framealpha=0.3)
plt.tight_layout()
plt.show()

fig2 = plt.figure(figsize=(11, 8))
ax = fig2.add_subplot(111, projection='3d')
G_mesh, LAM_mesh = np.meshgrid(G_grid, lambda_grid)
U_surface = TPS_grid[None, :] - LAM_mesh * TPS_REF * (1.0 - D_grid[None, :])

surf = ax.plot_surface(G_mesh/1e9, LAM_mesh, U_surface, cmap='plasma', edgecolor='none', alpha=0.92, antialiased=True)
ax.plot(G_star_arr/1e9, lambda_grid, U_star_arr, color='#00ff9f', lw=3.2, label='Optimal ridge G*(lambda)')

ax.xaxis.pane.set_facecolor((0.06, 0.06, 0.08, 1.0))
ax.yaxis.pane.set_facecolor((0.06, 0.06, 0.08, 1.0))
ax.zaxis.pane.set_facecolor((0.06, 0.06, 0.08, 1.0))
ax.set_xlabel('Gas limit G [billion gas]')
ax.set_ylabel('Decentralization weight $\\lambda$')
ax.set_zlabel('Utility U(G, $\\lambda$)')
ax.set_title('Utility Surface: Throughput minus Decentralization Penalty')
fig2.colorbar(surf, shrink=0.55, aspect=14, label='Utility')
ax.legend(loc='upper left')
ax.view_init(elev=28, azim=-55)
plt.tight_layout()
plt.show()

fig3, ax3 = plt.subplots(figsize=(9.5, 7.2))
sc = ax3.scatter(D_star_arr*100, TPS_star_arr, c=lambda_grid, cmap='cool', s=55,
edgecolor='white', linewidth=0.4, zorder=3)
ax3.plot(D_star_arr*100, TPS_star_arr, color='gray', alpha=0.4, lw=1.2, zorder=2)
cbar = fig3.colorbar(sc, label='$\\lambda$ (decentralization weight)')
ax3.set_xlabel('Decentralization fraction D*($\\lambda$) [%]')
ax3.set_ylabel('Optimal throughput TPS*($\\lambda$)')
ax3.set_title('Pareto Frontier: Throughput vs. Decentralization')
ax3.grid(alpha=0.25)

for i in [0, 9, 19, 29, 39, 49]:
ax3.annotate(f'{G_star_arr[i]/1e9:.2f}B gas', (D_star_arr[i]*100, TPS_star_arr[i]),
textcoords="offset points", xytext=(8, 6), fontsize=8.5, color='#ffd93d')

plt.tight_layout()
plt.show()

Output

======================================================================
Global optimizer cross-check at lambda = 1.0
======================================================================
Bounded local search : G* = 2.0669 billion gas | U = 1380.85
Differential Evolution: G* = 2.0574 billion gas | U = 1381.19

======================================================================
Pareto-optimal gas limits under different decentralization weights
======================================================================
  lambda |   G* [B gas] |  Block size [MB] |      TPS |  Orphan % |  D(G*) %
     0.0 |        7.500 |            26.25 |   3800.3 |     63.52 |    15.00
     0.5 |        5.736 |            20.08 |   3677.2 |     53.85 |    25.43
     1.0 |        2.067 |             7.23 |   2161.2 |     24.72 |    79.47
     2.0 |        1.198 |             4.19 |   1406.4 |     15.47 |    94.33
     3.0 |        0.832 |             2.91 |   1025.5 |     11.24 |    98.20
     5.0 |        0.698 |             2.44 |    875.8 |      9.64 |    99.10

Pure throughput-maximizing gas limit (lambda=0 reference): 7.480 billion gas
  -> at this point decentralization fraction D(G) = 15.08%

Code walkthrough

Physical constants. BYTES_PER_GAS converts gas units to raw block bytes using an empirically reasonable average transaction footprint. MEDIAN_BW sets the network’s typical node bandwidth (25 Mbps), and SIGMA_LOG controls how heterogeneous the network is — a log-normal distribution is a natural choice here because real-world node bandwidth is right-skewed: most nodes cluster around a modest broadband connection, with a long tail of well-connected data-center nodes and a lower tail of constrained home nodes.

prop_time(G, bw) is the single low-level physics function everything else is built on. It computes how long it takes a node with bandwidth bw to receive and relay a block of gas limit G. Because it’s written with pure NumPy broadcasting, the exact same function serves three different purposes in the script: scalar-vs-scalar (single node, single gas limit, used inside the optimizer), array-vs-scalar (whole network vs. one gas limit), and array-vs-array (the full node × gas-limit matrix used for the 3D grid).

decentralization_fraction(G) simulates the entire node population’s propagation times for a single gas limit and returns the fraction that stays within the safety margin F_MARGIN * T_BLOCK. This is effectively a Monte Carlo estimate of the CDF in the formula above — it deliberately uses the full 6,000-node sample rather than a closed-form log-normal CDF, so the model can later be extended with arbitrary, non-log-normal bandwidth distributions (e.g. real measured network data) with zero code changes.

orphan_rate(G) and throughput_tps(G) implement the propagation-race and effective-throughput formulas directly. Because they only depend on the median bandwidth rather than the whole distribution, they’re cheap to evaluate on large arrays — this is what lets TPS_grid and orphan_grid be computed as one-line vectorized calls over the full 300-point gas-limit grid.

The optimization loop. For each candidate decentralization weight $\lambda$ in lambda_grid, scipy.optimize.minimize_scalar with method='bounded' performs a fast 1-D bounded search (Brent-style golden-section/parabolic interpolation) to find the utility-maximizing gas limit. Only bounds are used — no constraint dictionaries are involved, keeping the call compatible across SciPy versions.

Cross-validation with differential_evolution. Bounded local search can in principle get stuck if the utility surface weren’t well-behaved, so as a sanity check the script also runs a global evolutionary search at $\lambda=1$ and prints both results side by side. They should agree closely, confirming the utility landscape has a single well-defined interior maximum rather than spurious local optima.

Why is there an interior optimum even at $\lambda=0$? This is one of the more interesting findings the model reproduces: since $p_{\text{orphan}}(G)$ grows with $G$, throughput itself is not monotonically increasing in the gas limit — past a certain point, blocks become so large that orphaning eats the gains faster than raw capacity grows. The script explicitly reports this “pure throughput-maximizing” gas limit as a reference point, and it turns out to sit at a fairly aggressive setting where decentralization has already collapsed to a small minority of well-connected nodes — a nice quantitative illustration of why “just maximize throughput” is a bad governance heuristic even before decentralization is considered.

Results

Graph 1 — Throughput / orphan rate / decentralization vs. gas limit. This dual-axis chart shows all three underlying quantities on the same gas-limit axis: throughput rises, peaks (marked by the dotted vertical line), and then declines as orphaning takes over; the orphan rate rises monotonically; and the decentralization fraction falls steadily as the block gets too large for an increasing share of the network to keep up in time.

Graph 2 — 3D utility surface. This is the full picture of the optimization problem: the surface plots $U(G,\lambda)$ across every combination of gas limit and decentralization weight, and the bright ridge line traces the optimal gas limit $G^{*}(\lambda)$ as $\lambda$ increases. Notice how the ridge shifts sharply toward smaller gas limits as $\lambda$ grows — a governance community that cares even moderately about decentralization should choose a meaningfully smaller block size than one optimizing for raw throughput alone.

Graph 3 — Pareto frontier. Each point is an optimal $(G^{*}, \lambda)$ pair, plotted as achievable throughput against the decentralization fraction it preserves, colored by $\lambda$. This is the chart a governance body would actually use in practice: it makes explicit that you cannot have both maximum throughput and maximum decentralization simultaneously, and it quantifies exactly how much throughput must be given up per percentage point of decentralization preserved. The annotated gas-limit values at each point turn the abstract frontier into concrete governance proposals.

Interpretation

The model captures, in a fully quantitative form, the intuition that has driven years of real-world gas limit and block size debates: bigger blocks are not a free lunch. There are two independent forces pulling against unlimited scaling — mechanical (orphaning eats throughput gains at the extreme) and social (large blocks price out smaller node operators, concentrating validation power). The Pareto frontier makes the trade-off explicit and tunable: a chain that prioritizes maximal decentralization would sit near the top-left of the frontier with a conservative gas limit, while a chain optimizing purely for near-term throughput — the kind of choice made by some high-throughput L1 designs — would sit toward the bottom-right, accepting a smaller, more centralized validator set in exchange for raw capacity. Because every parameter in this model (bandwidth distribution, block interval, safety margin, protocol efficiency) is independently adjustable, the same framework can be re-parameterized to study any specific chain’s governance question by plugging in its measured network characteristics.