Getting Started with Bicep’s JSON-RPC Library: A Practical Guide

Learn how you can use the JSON-RPC library to speed up your Bicep compile or any other operations

Getting Started with Bicep’s JSON-RPC Library: A Practical Guide

The Bicep team shipped a new library to NuGet. It's called Azure.Bicep.RpcClient.

This library allows you to interact with Bicep without needing to use the command line. You write .NET code, and the library handles the rest. It's fast, clean, and it works.

But why does this library matter? Because the spawning processes are slow. Parsing console output is fragile, unless you use the --stdout or --outfile options.

This library changes that. You get direct access to Bicep's compiler through JSON-RPC. One connection, multiple operations, meaning less overhead.

Let me demonstrate its functionality in this blog post.

What is JSON-RPC?

JSON-RPC is a protocol. It sends messages between programs. And as you can already recognize, these messages are JSON. The protocol is quite simple. You send a request like:

{
  "jsonrpc": "2.0",
  "method": "compile",
  "params": { "path": "main.bicep" },
  "id": 1
}

And you get back a response:

{
  "jsonrpc": "2.0",
  "result": { "success": true, "contents": "..." },
  "id": 1
}

That's it. No complexity, no parsing. Just structured data going back and forth. The Bicep CLI supported JSON-RPC since version 0.29 and above, as seen in the source code:

/// <summary>
/// The definition for the Bicep CLI JSONRPC interface.
/// </summary>
/// <remarks>
/// As of Bicep 0.29, this interface is no longer "experimental". Please consider carefully whether you are making a change that may break backwards compatibility.
/// </remarks>

You can use it with bicep jsonrpc --stdio. The RPC client wraps this for you, so you don't see the protocol. You just call the methods.

Why should I not use Bicep's CLI directly?

Good question. Let's look at what it takes to use the CLI from code. Imagine you're running Bicep's CLI from PowerShell:

# Compile a Bicep file using CLI
$out = & bicep build main.bicep --stdout

This works, but behind the scenes, what happens is the following:

  1. PowerShell spawns a new process.
  2. The process starts up (cold start penalty).
  3. Bicep loads assemblies and initializes.
  4. The operation runs.
  5. Output goes to stdout as text.
  6. You parse the text to get results.
  7. Process exit.

Every. Single. Time.

Want to compile five files? That's five process spawns. Five cold starts. Five text parsers. It adds up.

The JSON-RPC way

Now let's compare that using JSON-RPC. The RPC Client establishes one connection to Bicep. That connection stays open. You send multiple requests through it. No process spawning. No repeated initialization. No text parsing.

Here's the same scenario with three files:

using var client = await clientFactory.DownloadAndInitialize(config, cancellationToken);

var result1 = await client.Compile(new CompileRequest("main.bicep"));
var result2 = await client.Compile(new CompileRequest("storage.bicep"));
var result3 = await client.Compile(new CompileRequest("network.bicep"));

One process, one initialization, and three operations with structured results. No need to parse it further. The difference is real. Let's prove it.

Performance comparison

I ran the tests myself using the same machine and files, and here's what I found.

Using a single operation test to compile one file:

Measure-Command {
    $result = & bicep build main.bicep --stdout
}

Result:

Days              : 0
Hours             : 0
Minutes           : 0
Seconds           : 2
Milliseconds      : 87
Ticks             : 20870652
TotalDays         : 2.41558472222222E-05
TotalHours        : 0.000579740333333333
TotalMinutes      : 0.03478442
TotalSeconds      : 2.0870652
TotalMilliseconds : 2087.0652

Now, for one file, 2 seconds is not much. But adding more files to the mix gradually increases it. Let's examine how the performance compares to JSON-RPC. I have created the following small program:

using Bicep.RpcClient;
using Bicep.RpcClient.Models;
using System.Diagnostics;

// Parse command-line arguments
if (args.Length < 1)
{
    Console.WriteLine("Usage: BicepRpcDemo <bicep-file> [iterations]");
    Console.WriteLine("Example: BicepRpcDemo main.bicep 10");
    return 1;
}

var bicepFile = args[0];
var iterations = args.Length > 1 ? int.Parse(args[1]) : 1;

if (!File.Exists(bicepFile))
{
    Console.WriteLine($"Error: File not found: {bicepFile}");
    return 1;
}

try
{
    // Create the factory
    var clientFactory = new BicepClientFactory(new HttpClient());

    // Initialize the client (downloads Bicep if needed)
    Console.WriteLine($"Initializing Bicep RPC client...");
    var initStopwatch = Stopwatch.StartNew();
    
    using var client = await clientFactory.DownloadAndInitialize(
        new BicepClientConfiguration(), 
        CancellationToken.None);
    
    initStopwatch.Stop();
    Console.WriteLine($"Initialization completed in {initStopwatch.ElapsedMilliseconds}ms\n");

    // Get Bicep version
    var version = await client.GetVersion();
    Console.WriteLine($"Using Bicep version: {version}");
    Console.WriteLine($"File: {bicepFile}");
    Console.WriteLine($"Iterations: {iterations}\n");

    // Compile the file multiple times
    var times = new List<long>();
    var totalStopwatch = Stopwatch.StartNew();

    for (int i = 1; i <= iterations; i++)
    {
        var iterationStopwatch = Stopwatch.StartNew();
        var result = await client.Compile(new CompileRequest(bicepFile));
        iterationStopwatch.Stop();

        times.Add(iterationStopwatch.ElapsedMilliseconds);
        
        if (result.Success)
        {
            Console.WriteLine($"  Iteration {i}: {iterationStopwatch.ElapsedMilliseconds}ms (Template size: {result.Contents.Length} bytes)");
        }
        else
        {
            Console.WriteLine($"  Iteration {i}: {iterationStopwatch.ElapsedMilliseconds}ms (FAILED)");
            foreach (var diagnostic in result.Diagnostics.Take(3))
            {
                Console.WriteLine($"    [{diagnostic.Level}] {diagnostic.Message}");
            }
        }
    }

    totalStopwatch.Stop();

    // Calculate statistics
    var avgTime = times.Average();
    var minTime = times.Min();
    var maxTime = times.Max();

    Console.WriteLine($"\nStatistics:");
    Console.WriteLine($"  Total time: {totalStopwatch.ElapsedMilliseconds}ms");
    Console.WriteLine($"  Average: {Math.Round(avgTime, 0)}ms");
    Console.WriteLine($"  Min: {minTime}ms");
    Console.WriteLine($"  Max: {maxTime}ms");
    Console.WriteLine($"  First iteration: {times[0]}ms (includes warmup)");
    
    if (iterations > 1)
    {
        var subsequentAvg = times.Skip(1).Average();
        Console.WriteLine($"  Subsequent iterations average: {Math.Round(subsequentAvg, 0)}ms");
    }

    return 0;
}
catch (Exception ex)
{
    Console.WriteLine($"Error: {ex.Message}");
    Console.WriteLine($"Stack trace: {ex.StackTrace}");
    return 1;
}

I have created a small sample script to measure the performance, targeting the same main.bicep file, but this time, looping over it ten times:

# CLI approach
Write-Host "Testing CLI..." -ForegroundColor Yellow
$cliTime = Measure-Command {
    1..10 | ForEach-Object { $null = & bicep build main.bicep --stdout 2>&1 }
}
Write-Host "CLI Total: $($cliTime.TotalMilliseconds)ms" -ForegroundColor Green

# RPC approach
Write-Host "`nTesting RPC Client..." -ForegroundColor Yellow
$rpcTime = Measure-Command {
    .\BicepRpcDemo\bin\Release\net9.0\BicepRpcDemo.exe main.bicep 10 | Out-Null
}
Write-Host "RPC Total: $($rpcTime.TotalMilliseconds)ms" -ForegroundColor Green

# Calculate speedup
$speedup = $cliTime.TotalMilliseconds / $rpcTime.TotalMilliseconds
Write-Host "`nSpeedup: $([math]::Round($speedup, 1))x faster" -ForegroundColor Magenta

The result is shown in the image below.

The JSON-RPC client wins because there's no process spawn overhead as the connection already exists. Are you convinced already?

Getting started

Ready to try it out yourself? Assuming you have the .NET 9.0 SDK installed, you can execute the following steps:

  1. Open a PowerShell terminal session.
  2. Run the following command to create a new console project and add the Azure.Bicep.RpcClient library to the project:
# Create a new console application
dotnet new console -n BicepRpcDemo

# Change the directory
cd BicepRpcDemo

# Add the package
dotnet add package Azure.Bicep.RpcClient --version 0.38.5
  1. Replace the Program.cs with this:
using Bicep.RpcClient;
using Bicep.RpcClient.Models;
using System.Diagnostics;

Console.WriteLine("Bicep RPC Client Demo\n");

// Create the factory
var clientFactory = new BicepClientFactory(new HttpClient());

// Initialize the client (downloads Bicep if needed)
Console.WriteLine("Initializing Bicep client...");
using var client = await clientFactory.DownloadAndInitialize(
    new BicepClientConfiguration(), 
    CancellationToken.None);

// Get Bicep version
var version = await client.GetVersion();
Console.WriteLine($"Using Bicep version: {version}\n");

// Create a test Bicep file
var testBicep = @"
param location string = 'eastus'
param storageAccountName string

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

output storageAccountId string = storageAccount.id
";

File.WriteAllText("test.bicep", testBicep);

// Compile the file
Console.WriteLine("Compiling test.bicep...");
var stopwatch = Stopwatch.StartNew();

var result = await client.Compile(new CompileRequest("test.bicep"));

stopwatch.Stop();

if (result.Success)
{
    Console.WriteLine($"✓ Compilation succeeded in {stopwatch.ElapsedMilliseconds}ms");
    Console.WriteLine($"  Template size: {result.Contents?.Length ?? 0} bytes\n");
}
else
{
    Console.WriteLine($"✗ Compilation failed in {stopwatch.ElapsedMilliseconds}ms");
    foreach (var diagnostic in result.Diagnostics)
    {
        Console.WriteLine($"  [{diagnostic.Level}] {diagnostic.Message}");
    }
}

// Get metadata
Console.WriteLine("Getting metadata...");
var metadata = await client.GetMetadata(new GetMetadataRequest("test.bicep"));

Console.WriteLine($"Parameters: {metadata.Parameters.Count()}");
foreach (var param in metadata.Parameters)
{
    Console.WriteLine($"  - {param.Name}: {param.Description}");
}

Console.WriteLine($"Outputs: {metadata.Exports.Count()}");
foreach (var output in metadata.Exports)
{
    Console.WriteLine($"  - {output.Name}: {output.Description}");
}

// Clean up
File.Delete("test.bicep");

Console.WriteLine("\nDone!");
  1. Run the application by executing dotnet run.

The result:

You have now successfully created your own Bicep JSON-RPC .NET project.

When to use the JSON-RPC client

You've seen the numbers and seen the code. Now the question is: when should you actually use this?

The answer depends on your workload. The JSON-RPC client excels in specific scenarios compared to the CLI. Here's how to choose.

The JSON-RPC client is the right choice when you:

  • Compile multiple files in a single execution.
  • Need structured results instead of parsing text output (even though most Bicep commands have a --stdout option).
  • Build tools that integrate with Bicep's functionality.
  • Process files concurrently for improved throughput (as evidenced by the numbers).

Stick with Bicep's CLI when you:

  • Run one-off commands interactively.
  • Use shell scripts that already work.
  • Don't need programmatic integration.
  • Can't (or want) to use .NET in your environment.

What's next?

The JSON-RPC client has just been released to NuGet. Keep in mind that the NuGet description also indicates that the library is not a supported package by the team. The team can make changes on the fly, and the team has reserved the right to push breaking changes.

As the Bicep team continues to add features, new JSON-RPC methods (hopefully) arrive with each Bicep release. The client should automatically support them.

But the core is solid and works today.

Final thoughts

The Bicep JSON-RPC client changes how you integrate Bicep into .NET applications. It is a fun experience to work with and perfectly illustrates how fast it is compared to shelling out to the CLI.

If you're building tools that use Bicep, this library is your go-to goat. The performance gains alone justify it. Try it out, measure it, and see the difference yourself.

The library is at NuGet.org. I went on myself to create docs around it (but is still open in a pull request).