Skip to main content

Batched Transactions on EVM: Execute Multiple Operations

Batched transactions combine multiple blockchain operations into a single transaction. Instead of submitting separate transactions (each with its own gas cost and confirmation), you execute everything atomically in one go.
Without Batching:
TX 1: Approve USDC    → Wait → Confirm → Pay gas
TX 2: Deposit to Aave → Wait → Confirm → Pay gas
TX 3: Claim rewards   → Wait → Confirm → Pay gas
Total: 3 signatures, 3 gas payments, 3 confirmations

With Batching:
TX 1: [Approve + Deposit + Claim] → Single signature → Single gas payment
Total: 1 signature, 1 gas payment, atomic execution
Batching is enabled by smart contract wallets (account abstraction).
BenefitDescription
Lower gas costsOne base transaction fee instead of multiple
Better UXUsers sign once instead of multiple times
Atomic executionAll operations succeed or all fail together
Faster completionNo waiting between steps
Reduced failure riskNo partial state from incomplete sequences
Gas savings example:
ScenarioWithout BatchingWith BatchingSavings
Approve + Swap~140k gas~95k gas32%
3 token transfers~120k gas~75k gas37%
Approve + Deposit + Claim~200k gas~130k gas35%
Using AbstractJS SDK with MEE:
import { createMeeClient, toMultichainNexusAccount } from "@biconomy/abstractjs";
import { encodeFunctionData } from "viem";

const account = await toMultichainNexusAccount({
  signer: userSigner,
  chains: [base]
});

const meeClient = await createMeeClient({ account });

// Batch multiple operations with MEE
const quote = await meeClient.getQuote({
  instructions: [
    {
      calls: [
        {
          to: usdcAddress,
          data: encodeFunctionData({
            abi: erc20Abi,
            functionName: "approve",
            args: [aavePool, amount]
          })
        },
        {
          to: aavePool,
          data: encodeFunctionData({
            abi: aaveAbi,
            functionName: "deposit",
            args: [usdcAddress, amount, userAddress, 0]
          })
        },
        {
          to: rewardsController,
          data: encodeFunctionData({
            abi: rewardsAbi,
            functionName: "claimAllRewards",
            args: [assets, userAddress]
          })
        }
      ]
    }
  ]
});

const { hash } = await meeClient.executeQuote({ quote });
console.log("All operations completed in one tx:", hash);
MEE (Modular Execution Environment) provides all the batching capabilities of ERC-4337 bundlers and paymasters, plus cross-chain orchestration. Use MEE instead of configuring bundlers and paymasters separately.
You can batch any combination of EVM operations:Common batching patterns:
PatternOperations
Token + ActionApprove → Swap/Deposit/Stake
Multi-sendMultiple token transfers in one tx
DeFi comboClaim → Swap → Deposit
NFT batchApprove → List multiple NFTs
GamingEquip + Upgrade + Consume items
DAODelegate → Vote on multiple proposals
Example: Multi-token transfer
const transfers = recipients.map(({ address, amount }) => ({
  to: tokenAddress,
  data: encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [address, amount]
  })
}));

// Send to 100 recipients in ONE transaction
await smartAccount.sendTransactions(transfers);
Atomic execution means all operations succeed or all fail. There’s no partial state.Why this matters:
Scenario: Approve USDC → Swap USDC for ETH

Non-atomic (separate txs):
1. Approve succeeds ✅
2. Swap fails (price changed) ❌
Result: Approval is stuck, USDC still approved to router 😰

Atomic (batched):
1. [Approve + Swap] fails ❌
Result: Nothing happened, state unchanged 😌
Biconomy ensures atomicity:
try {
  const { hash } = await smartAccount.sendTransactions([
    { /* operation 1 */ },
    { /* operation 2 */ },
    { /* operation 3 */ }
  ]);
  // All succeeded
} catch (error) {
  // ALL operations reverted, no partial state
  console.log("Batch failed atomically");
}
Yes! Use runtime values to reference outputs from earlier operations:
import { runtime } from "@biconomy/abstractjs";

const instructions = [
  // Step 1: Swap ETH for USDC (output unknown until execution)
  {
    type: "intent",
    intent: "swap",
    params: {
      from: "ETH",
      to: "USDC",
      amount: "1.0"
    }
  },
  // Step 2: Use EXACT output from step 1
  {
    to: aavePool,
    data: encodeDeposit(
      usdcAddress,
      runtime.outputOf(0), // Dynamic: uses actual swap output
      userAddress
    )
  }
];
The runtime.outputOf(0) is resolved at execution time with the actual value from the first operation.
Batch size is limited by:
  1. Block gas limit: Total gas must fit in a block
  2. Bundler limits: Typically 5-10M gas per UserOperation
  3. Practical limits: More operations = higher failure risk
Guidelines:
ChainRecommended Max OperationsMax Gas
Ethereum10-203-5M gas
Arbitrum20-5010M gas
Base20-5010M gas
Polygon20-508M gas
Best practices:
// Split large batches
const BATCH_SIZE = 20;

async function batchTransfer(transfers) {
  const results = [];
  
  for (let i = 0; i < transfers.length; i += BATCH_SIZE) {
    const batch = transfers.slice(i, i + BATCH_SIZE);
    const result = await smartAccount.sendTransactions(batch);
    results.push(result);
  }
  
  return results;
}
// Estimate gas for batch
const gasEstimate = await smartAccount.estimateGas({
  transactions: [
    { to: contract1, data: data1 },
    { to: contract2, data: data2 },
    { to: contract3, data: data3 }
  ]
});

console.log("Total gas estimate:", gasEstimate);

// Compare with individual estimates
const individual1 = await publicClient.estimateGas({ to: contract1, data: data1 });
const individual2 = await publicClient.estimateGas({ to: contract2, data: data2 });
const individual3 = await publicClient.estimateGas({ to: contract3, data: data3 });

const totalIndividual = individual1 + individual2 + individual3;
const savings = totalIndividual - gasEstimate;

console.log(`Gas savings from batching: ${savings} (${(savings/totalIndividual*100).toFixed(1)}%)`);
Default behavior: Atomic revert
try {
  await smartAccount.sendTransactions([op1, op2, op3]);
} catch (error) {
  // Entire batch reverted
  // Identify which operation failed from error message
  console.log("Batch failed:", error.message);
}
For non-critical operations, use conditional execution:
const { conditions } = require("@biconomy/abstractjs");

const transactions = [
  // Critical: Always execute
  { to: contract1, data: data1 },
  
  // Optional: Only if balance exists
  {
    to: contract2,
    data: data2,
    condition: conditions.hasBalance(tokenAddress, minAmount)
  },
  
  // Optional: Only if previous succeeded
  {
    to: contract3,
    data: data3,
    condition: conditions.previousSucceeded()
  }
];
Pre-validate operations:
async function validateBatch(transactions) {
  const simulations = await Promise.all(
    transactions.map(tx => 
      publicClient.simulateContract({
        address: tx.to,
        data: tx.data
      }).catch(e => ({ error: e }))
    )
  );
  
  const failures = simulations.filter(s => s.error);
  if (failures.length > 0) {
    console.log("Operations that would fail:", failures);
    return false;
  }
  return true;
}
Yes! Cross-chain batching is possible with Biconomy’s orchestration:
const crossChainBatch = {
  instructions: [
    // Chain 1: Ethereum
    {
      chainId: 1,
      to: usdcEthereum,
      data: encodeApprove(bridge, amount)
    },
    // Bridge operation
    {
      type: "intent",
      intent: "bridge",
      params: { token: "USDC", from: 1, to: 8453, amount }
    },
    // Chain 2: Base
    {
      chainId: 8453,
      to: aaveBase,
      data: encodeDeposit(usdcBase, runtime.bridgeOutput, user)
    }
  ]
};

// One signature covers all chains
const result = await meeClient.execute(crossChainBatch);
See Multi-Chain Execution for details.
Example 1: DeFi Yield Optimization
// Claim rewards → Swap to stablecoin → Redeposit
await smartAccount.sendTransactions([
  // Claim pending rewards
  { to: farm, data: encodeClaim() },
  // Approve swap
  { to: rewardToken, data: encodeApprove(router, maxUint256) },
  // Swap rewards to USDC
  { to: router, data: encodeSwap(rewardToken, usdc, rewardAmount) },
  // Deposit USDC back into farm
  { to: farm, data: encodeDeposit(usdc, usdcAmount) }
]);
Example 2: NFT Marketplace Listing
// Approve collection → List multiple NFTs
const listings = tokenIds.map(id => ({
  to: marketplace,
  data: encodeList(collection, id, price)
}));

await smartAccount.sendTransactions([
  { to: collection, data: encodeSetApprovalForAll(marketplace, true) },
  ...listings
]);
Example 3: Payroll Distribution
// Approve total → Transfer to all employees
const totalAmount = employees.reduce((sum, e) => sum + e.amount, 0n);

const transfers = employees.map(({ address, amount }) => ({
  to: paymentToken,
  data: encodeTransfer(address, amount)
}));

await smartAccount.sendTransactions([
  { to: paymentToken, data: encodeApprove(batchContract, totalAmount) },
  ...transfers
]);

Learn more