eth_feeHistory reward percentiles provide a per-block view of the effective priority fee paid by transactions at chosen percentiles of the block's fee-ordered transactions. Unlike base fee, which is fixed by the EIP-1559 protocol rule from the previous block's gas usage, the priority fee is an estimate and the percentile is a control input. A self-correcting estimator submits transactions, observes inclusion latency, and moves the percentile up or down against a target with hysteresis. This article provides runnable Node.js code for the estimator and the feedback loop, a results table to fill against your own endpoint, and troubleshooting for provider caps, zero-reward arrays, and unfinalised blocks.
The Request Contract for eth_feeHistory
The Ethereum JSON-RPC specification defines eth_feeHistory with three parameters: blockCount (the number of blocks to query), newestBlock (the highest block number or tag such as 'latest'), and rewardPercentiles (an array of percentile values between 0 and 100). The rewardPercentiles array must be monotonically increasing; the response reward array aligns to these percentiles in the same order. This is the contract you build against, and it is documented in the Ethereum JSON-RPC specification.
The response contains four arrays. oldestBlock is the block number of the first block in the window. baseFeePerGas has length blockCount + 1 because it includes the base fee for the block after the last queried block, which is the next block's base fee. gasUsedRatio has length blockCount and gives the fraction of gas used in each block. reward is an array of arrays: for each block, an array of effective priority fees paid by transactions at the requested percentiles, in wei. A common trap is unit conversion: all values are in wei, and converting to gwei requires dividing by 1e9. If you pass wei values into a gwei-denominated field, your transaction will be underpriced by a factor of one billion.
The reward array is the only part of the response that reflects what users actually paid. It is not a prediction; it is a historical observation of effective priority fees at the chosen percentiles. The percentile you choose determines which transaction in each block's fee-ordered set you are sampling. A low percentile samples cheap transactions that may have waited many blocks; a high percentile samples transactions that paid more to land quickly.
- blockCount: number of blocks to query; providers may cap this value.
- newestBlock: highest block number or 'latest'; must be a block that exists on the canonical chain.
- rewardPercentiles: monotonically increasing array of numbers between 0 and 100.
- oldestBlock: first block number in the returned window.
- baseFeePerGas: array of length blockCount + 1; the last element is the next block's base fee.
- gasUsedRatio: array of length blockCount; fraction of gas used per block.
- reward: array of length blockCount; each element is an array of effective priority fees aligned to rewardPercentiles.
Base Fee Is Protocol-Fixed, Not a Prediction Problem
EIP-1559 defines the base fee update rule: the base fee for the next block is a function of the previous block's gas usage relative to the target. If the previous block used exactly the target, the base fee stays the same. If it used more, the base fee increases; if less, it decreases. The change is bounded by a maximum factor per block. This rule is deterministic and documented in EIP-1559.
Because the base fee is fixed by the protocol, your estimator should compute the next base fee from the rule rather than extrapolate or average historical base fees. Averaging introduces lag and can underprice during rising base fee periods. The independent analysis in Base Fee Manipulation In Ethereum's EIP-1559 Transaction Fee Mechanism shows that base fee behaviour is policy-driven and can be influenced by miners or validators across blocks, which reinforces that it is not a demand-prediction problem. Use the protocol rule, not a statistical model.
The practical implication is that maxFeePerGas should be set to nextBaseFee * safetyMultiplier + maxPriorityFeePerGas. The safety multiplier accounts for the possibility that the base fee rises before your transaction is included. A common choice is 1.125 or 1.25, but the correct value depends on how many blocks you are willing to wait and how volatile the base fee is on your chain.
The Priority Fee Percentile as a Control Input
The priority fee is the part of the fee that goes to the block producer. Unlike base fee, it is not fixed by the protocol; it is a market outcome. The rewardPercentiles parameter lets you sample the distribution of effective priority fees within each block. For example, rewardPercentiles: [10, 50, 90] returns, for each block, the effective priority fee paid by the transaction at the 10th, 50th, and 90th percentile of that block's fee-ordered transactions.
A percentile that is too low produces a transaction that is not included, because it is outbid by other transactions. A percentile that is too high overpays, because you are paying more than necessary to land. The correct percentile is not a fixed number; it depends on current network conditions, the value you place on inclusion latency, and the behaviour of other transactions in the blocks you are sampling.
This is the part that most provider documentation does not explain: the percentile is a control input whose correct value depends on observed landing outcomes. You cannot copy a percentile from a blog post and expect it to work across chains and market conditions. You must measure your own inclusion latency and adjust.
The Closed Loop: Submit, Observe, Adjust
A self-correcting estimator treats the percentile as a variable in a feedback loop. The loop has four stages: estimate, submit, observe, adjust. In the estimate stage, you call eth_feeHistory with your current percentile set and compute maxFeePerGas and maxPriorityFeePerGas. In the submit stage, you sign and send the transaction. In the observe stage, you record the block number at submission and the block number at inclusion, and compute the inclusion latency in blocks. In the adjust stage, you compare the observed latency to a target and move the percentile up or down.
Hysteresis is important to avoid oscillation. If you adjust the percentile on every transaction, you may overshoot and create a cycle of overpaying and underpaying. A simple approach is to adjust only when the observed latency deviates from the target by more than a threshold, or to adjust by a small step and require multiple observations before a larger change. For example, if the target is two blocks and the observed latency is five blocks, increase the percentile by 5 points. If the observed latency is one block, decrease by 2 points. If the observed latency is two or three blocks, do nothing.
This closed loop is the part no first-page result covers. Provider documentation describes the request and response, but not how to use the response to drive a control decision. The loop is what turns eth_feeHistory from a data source into a self-correcting estimator.
- Estimate: call eth_feeHistory with the current percentile set.
- Submit: sign and send the transaction with the computed fees.
- Observe: record submission block and inclusion block; compute latency in blocks.
- Adjust: compare latency to target; move percentile up or down with hysteresis.
Runnable Node.js Estimator Using eth_feeHistory
The following Node.js script calls eth_feeHistory with a configurable percentile set, computes the next base fee from the EIP-1559 rule, takes the priority fee at a chosen percentile across the window (using the median of per-block values, not a single block), adds a safety multiplier, and emits maxFeePerGas and maxPriorityFeePerGas. It uses the built-in fetch API and assumes a JSON-RPC endpoint URL in the environment variable RPC_URL.
The script computes the next base fee by applying the EIP-1559 rule to the last block in the window. It uses the gasUsedRatio of the last block and the baseFeePerGas of the last block to compute the next base fee. It then takes the median of the reward values at the chosen percentile index across all blocks in the window. This median is more robust than a single block's value, which may be an outlier.
The safety multiplier is applied to the base fee component only. The priority fee is added after the multiplier. This matches the EIP-1559 semantics: maxFeePerGas is the maximum total fee per gas, and maxPriorityFeePerGas is the maximum priority fee per gas. The transaction is valid only if maxFeePerGas >= baseFeePerGas + maxPriorityFeePerGas at inclusion time.
const RPC_URL = process.env.RPC_URL;
const PERCENTILES = [10, 25, 50, 75, 90];
const CHOSEN_PERCENTILE_INDEX = 2; // 50th percentile
const BLOCK_COUNT = 20;
const SAFETY_MULTIPLIER = 1.125;
async function rpc(method, params) {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const json = await res.json();
if (json.error) throw new Error(JSON.stringify(json.error));
return json.result;
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function nextBaseFee(lastBaseFee, lastGasUsedRatio) {
const target = 0.5;
const maxChange = 0.125;
const delta = (lastGasUsedRatio - target) / target;
const bounded = Math.max(-maxChange, Math.min(maxChange, delta));
return Math.floor(lastBaseFee * (1 + bounded));
}
async function estimateFees() {
const history = await rpc('eth_feeHistory', [
'0x' + BLOCK_COUNT.toString(16),
'latest',
PERCENTILES
]);
const baseFees = history.baseFeePerGas.map((hex) => parseInt(hex, 16));
const gasRatios = history.gasUsedRatio;
const rewards = history.reward.map((blockRewards) =>
blockRewards.map((hex) => parseInt(hex, 16))
);
const lastBaseFee = baseFees[baseFees.length - 2];
const lastGasRatio = gasRatios[gasRatios.length - 1];
const nextBase = nextBaseFee(lastBaseFee, lastGasRatio);
const prioritySamples = rewards.map((block) => block[CHOSEN_PERCENTILE_INDEX]);
const medianPriority = median(prioritySamples);
const maxPriorityFeePerGas = Math.ceil(medianPriority);
const maxFeePerGas = Math.ceil(nextBase * SAFETY_MULTIPLIER) + maxPriorityFeePerGas;
return {
nextBaseFee: nextBase,
medianPriority,
maxPriorityFeePerGas,
maxFeePerGas,
maxPriorityFeePerGasGwei: maxPriorityFeePerGas / 1e9,
maxFeePerGasGwei: maxFeePerGas / 1e9
};
}
estimateFees().then(console.log).catch(console.error);Measuring Landing Rate and Updating the Percentile
The second runnable snippet measures landing rate from observed inclusion data and updates the percentile. It assumes you have a list of recent transactions with their submission block, inclusion block, and the percentile used. It computes the average inclusion latency and adjusts the percentile up or down with hysteresis. The adjustment logic is intentionally simple: if the average latency exceeds the target by more than one block, increase the percentile by a step; if it is below the target by more than one block, decrease by a smaller step; otherwise, leave it unchanged.
The snippet also computes an overpayment metric: the difference between the priority fee you paid and the median priority fee at the chosen percentile in the inclusion block. This helps you distinguish between 'landed' and 'landed cheaply'. A transaction that lands in one block but pays the 90th percentile when the 50th percentile would have sufficed is a candidate for a lower percentile.
In production, you would persist the percentile and the observation history in a database or a file. The snippet uses an in-memory object for clarity. The key point is that the percentile is not a constant; it is a state variable that evolves with observed outcomes.
const TARGET_LATENCY_BLOCKS = 2;
const HYSTERESIS_BLOCKS = 1;
const UP_STEP = 5;
const DOWN_STEP = 2;
const MIN_PERCENTILE = 5;
const MAX_PERCENTILE = 95;
function updatePercentile(currentPercentile, observations) {
if (observations.length === 0) return currentPercentile;
const avgLatency =
observations.reduce((sum, o) => sum + (o.inclusionBlock - o.submissionBlock), 0) /
observations.length;
const deviation = avgLatency - TARGET_LATENCY_BLOCKS;
if (deviation > HYSTERESIS_BLOCKS) {
return Math.min(MAX_PERCENTILE, currentPercentile + UP_STEP);
}
if (deviation < -HYSTERESIS_BLOCKS) {
return Math.max(MIN_PERCENTILE, currentPercentile - DOWN_STEP);
}
return currentPercentile;
}
function overpayment(paidPriorityFee, medianPriorityFeeAtInclusion) {
return paidPriorityFee - medianPriorityFeeAtInclusion;
}
// Example usage
const observations = [
{ submissionBlock: 100, inclusionBlock: 105, percentile: 50, paidPriorityFee: 2e9 },
{ submissionBlock: 106, inclusionBlock: 108, percentile: 50, paidPriorityFee: 2e9 },
{ submissionBlock: 109, inclusionBlock: 110, percentile: 50, paidPriorityFee: 2e9 }
];
const currentPercentile = 50;
const newPercentile = updatePercentile(currentPercentile, observations);
console.log({ currentPercentile, newPercentile });
const over = overpayment(2e9, 1.5e9);
console.log({ overpaymentWei: over, overpaymentGwei: over / 1e9 });Results Table for Your Own Endpoint
Use the following table to record measurements against your own endpoint. Run the estimator with different percentiles, submit transactions, and record the median reward at the chosen percentile, the blocks to inclusion, and the overpayment relative to the median reward at inclusion. The goal is to find the lowest percentile that consistently meets your target inclusion latency.
Fill in the table over a period of at least a few hours to capture variation in network conditions. A single sample is not enough to draw conclusions. If you are testing on a low-activity chain, you may need to wait longer or use a testnet with controlled traffic.
The overpayment column is the difference between the priority fee you paid and the median priority fee at the same percentile in the inclusion block. A positive value means you paid more than the median; a negative value means you paid less. Consistently positive overpayment suggests you can lower the percentile.
- Percentile: the rewardPercentiles value used for the estimate.
- Median reward (gwei): the median of the per-block reward values at that percentile.
- Blocks to inclusion: inclusion block minus submission block.
- Overpayment (gwei): paid priority fee minus median priority fee at inclusion.
Failure Modes and Troubleshooting
Provider blockCount caps are a common failure mode. Many providers limit the number of blocks you can query in a single eth_feeHistory call. When you exceed the cap, the provider returns a JSON-RPC error object. The JSON-RPC 2.0 Specification defines the error envelope: a code, a message, and optional data. You must decode this error and handle it, not swallow it. A robust estimator catches the error, reduces blockCount, and retries. If you ignore the error, your estimator may use stale or missing data.
An all-zero reward array can occur on low-activity chains or during periods when no transactions paid a priority fee. In this case, the median priority fee is zero, and your transaction may be included with a zero priority fee if the base fee is sufficient. However, a zero priority fee may not be accepted by all block producers. A fallback is to use eth_maxPriorityFeePerGas, which returns a suggested priority fee, or to set a minimum priority fee floor.
A newestBlock that is not yet finalised can cause your estimator to use a block that may be reorged. If the block is reorged, the base fee and reward data may change. For most use cases, using 'latest' is acceptable, but for high-value transactions, you may want to use a block that is a few blocks behind the head to reduce reorg risk. The tradeoff is that the data is slightly stale.
The maxPriorityFeePerGas fallback is useful when the history window is unrepresentative. If the window contains only low-activity blocks, the reward percentiles may be zero or near zero. In that case, eth_maxPriorityFeePerGas provides a provider-suggested value that may be more appropriate. Documented behaviour varies by provider; some providers compute this value from recent blocks, while others use a fixed heuristic.
- Provider blockCount cap: decode the JSON-RPC error object and retry with a smaller blockCount.
- All-zero reward array: use eth_maxPriorityFeePerGas or set a minimum priority fee floor.
- Unfinalised newestBlock: use a block a few behind the head for high-value transactions.
- Unrepresentative history window: fall back to eth_maxPriorityFeePerGas or widen the window.
Limitations and Tradeoffs
Gas estimation traffic has a cost. Every eth_feeHistory call consumes provider resources and may count against your rate limits. A self-correcting estimator that calls eth_feeHistory on every transaction may generate more traffic than a simple estimator that caches the result for a few blocks. The tradeoff is between responsiveness and cost. For most applications, caching the result for one or two blocks is sufficient.
A percentile from history cannot predict a spike. If a large transaction or a batch of transactions enters the mempool, the priority fee required for timely inclusion can rise sharply. Historical percentiles reflect past conditions, not future demand. The safety multiplier on the base fee helps with base fee spikes, but the priority fee component is not protected. For time-sensitive transactions, consider a higher percentile or a dynamic multiplier.
The difference between 'landed' and 'landed cheaply' matters. A transaction that lands in one block at the 90th percentile is not necessarily better than a transaction that lands in two blocks at the 50th percentile. The former overpays; the latter may be more cost-effective. Your target latency should reflect the value of inclusion speed for your use case. For a non-urgent transfer, two or three blocks may be acceptable; for an arbitrage transaction, one block may be essential.
The closed loop requires observation. You must record submission and inclusion blocks for your own transactions. This is straightforward if you control the sending wallet, but it is more difficult if you are estimating for a third party. In that case, you can use a public dataset or a block explorer API to observe inclusion latency for transactions with similar fee parameters.