Video: Make your string operations faster!

In this video I try to performance optimize a base64 module written by A.J. Kauffmann:

Find AJ’s stuff at:

https://www.kauffmann.nl/

https://github.com/ajkauffmann/


In this video, Erik demonstrates a common performance pitfall when working with string operations in Business Central (AL). He shows how repeatedly concatenating strings in a loop leads to exponentially degrading performance, and how switching to the built-in TextBuilder data type can make your code over 100 times faster — with just a few lines of changes.

The Problem: String Concatenation in Loops

Erik starts by referencing a Base64 conversion codeunit originally created by AJ Kauffmann back in 2017. At that time, Business Central didn’t have built-in Base64 support, so AJ’s pure-AL implementation was incredibly useful. Erik had used this codeunit in many projects over the years — until earlier this week, when some code suddenly started running very slowly.

The root cause? String concatenation inside tight loops. Let’s look at what happens when you write something like this in AL:

ReturnValue := ReturnValue + BinaryValue;

Every time this line executes, AL doesn’t simply append characters to the end of the existing string. Instead, it creates an entirely new string by copying the old content plus the new content. So on each iteration:

  • Iteration 1: Creates a string of 7 characters
  • Iteration 2: Copies 7 + 7 = creates a new 14-character string
  • Iteration 3: Copies 14 + 7 = creates a new 21-character string
  • …and so on for thousands of iterations

As the string grows, each concatenation becomes more and more expensive because it has to copy an ever-larger block of memory. This results in quadratic (O(n²)) performance rather than linear, meaning doubling your input more than doubles the processing time.

Measuring the Performance Impact

Erik built a simple test page to measure the difference between Microsoft’s built-in Base64 conversion (available since Business Central v16) and AJ’s pure-AL implementation:

page 50600 "Perf Test Page"
{
    Caption = 'Perf Test Page';
    UsageCategory = Lists;
    ApplicationArea = all;
    PageType = Card;
    layout
    {
        area(Content)
        {
            field(DataSize; DataSize)
            {
                Caption = 'Data Size to test';
                ApplicationArea = all;
                Editable = true;
            }
            field(T1; T1)
            {
                Caption = 'Builtin Version';
                ApplicationArea = all;
                Editable = false;
            }
            field(T2; T2)
            {
                Caption = 'AJ Version';
                ApplicationArea = all;
                Editable = false;
            }
        }
    }
    actions
    {
        area(Processing)
        {
            action(Test)
            {
                Caption = 'Test';
                Promoted = true;
                PromotedCategory = Process;
                PromotedIsBig = true;
                PromotedOnly = true;
                ApplicationArea = all;
                Image = TestDatabase;
                trigger OnAction()
                var
                    TestTxt: Text;
                    Res1, Res2 : Text;
                    Ch: Text[1];
                    i: Integer;
                    Convert1: Codeunit "Base64 Convert";
                    Convert2: Codeunit "Base64 Handler";
                    StartTime: Time;
                    D: Dialog;
                    TB: TextBuilder;
                begin
                    D.Open('Running Test');
                    for i := 1 to DataSize do begin
                        ch[1] := (i mod 32) + 64;
                        TB.Append(ch);
                    end;
                    TestTxt := TB.ToText();
                    StartTime := Time();
                    Res1 := Convert1.ToBase64(TestTxt);
                    T1 := Time() - StartTime;
                    StartTime := Time();
                    Res2 := Convert2.TextToBase64String(TestTxt);
                    T2 := Time() - StartTime;
                    D.Close();
                end;
            }
        }
    }
    var
        T1, T2 : Integer;
        DataSize: Integer;
}

Notice that even the test string generation itself uses TextBuilder — Erik initially had the same concatenation problem there too and fixed it during the video.

The benchmark results told a clear story:

Data Size Built-in (ms) AJ Version – Before Fix (ms)
1,000 ~0 ~7
2,000 ~0 ~47-62
20,000 ~0 ~1,200-1,600
40,000 ~0 ~6,000
60,000 ~0 ~12,000
200,000 ~0 ~133,000

The non-linear growth is obvious: tripling the input from 20,000 to 60,000 resulted in nearly 10x the processing time, confirming quadratic behavior.

The Solution: TextBuilder

Business Central includes a built-in data type called TextBuilder, which is the AL equivalent of C#’s StringBuilder. Instead of creating a new string on every concatenation, TextBuilder collects all the pieces internally and only assembles the final string once, when you call ToText().

Here’s how C#’s StringBuilder works conceptually — and TextBuilder follows the same pattern:

  1. Create a TextBuilder variable
  2. Call Append() to add string fragments
  3. Call ToText() at the end to get the final assembled string

The actual string assembly only happens once at the end, eliminating the repeated copying overhead.

Applying the Fix

The fix required changes in just two procedures. Here’s the optimized codeunit with TextBuilder integrated into the key loops:

codeunit 50600 "Base64 Handler"
{
    procedure TextToBase64String(Value: Text) ReturnValue: Text;
    var
        BinaryValue: text;
        Length: Integer;
    begin
        Length := StrLen(Value);
        BinaryValue := TextToBinary(Value, 8);
        ReturnValue := ConvertBinaryValueToBase64String(BinaryValue, Length);
    end;

    local procedure ConvertBinaryValueToBase64String(Value: Text; Length: Integer) ReturnValue: Text;
    var
        Length2: Integer;
        PaddingCount: Integer;
        BlockCount: Integer;
        Pos: Integer;
        CurrentByte: text;
        i: Integer;
        TB: TextBuilder;
    begin
        if Length MOD 3 = 0 then begin
            PaddingCount := 0;
            BlockCount := Length / 3;
        end else begin
            PaddingCount := 3 - (Length MOD 3);
            BlockCount := (Length + PaddingCount) / 3;
        end;

        Length2 := Length + PaddingCount;
        Value := PadStr(Value, Length2 * 8, '0');

        Pos := 1;
        while Pos < Length2 * 8 do begin
            CurrentByte := CopyStr(Value, Pos, 6);
            // OLD: ReturnValue += GetBase64Char(BinaryToInt(CurrentByte));
            TB.Append(GetBase64Char(BinaryToInt(CurrentByte)));
            pos += 6;
        end;
        ReturnValue := TB.ToText();

        for i := 1 to PaddingCount do begin
            Pos := StrLen(ReturnValue) - i + 1;
            ReturnValue[Pos] := '=';
        end;
    end;

    local procedure TextToBinary(Value: text; ByteLength: Integer) ReturnValue: text;
    var
        IntValue: Integer;
        i: Integer;
        BinaryValue: text;
        TB: TextBuilder;
    begin
        for i := 1 to StrLen(value) do begin
            IntValue := value[i];
            BinaryValue := IntToBinary(IntValue);
            BinaryValue := IncreaseStringLength(BinaryValue, ByteLength);
            // OLD: ReturnValue += BinaryValue;
            TB.Append(BinaryValue);
        end;
        ReturnValue := TB.ToText();
    end;

    // ... remaining procedures unchanged ...
}

The pattern is identical in both places:

  1. Declare a TB: TextBuilder variable
  2. Replace ReturnValue += SomeValue with TB.Append(SomeValue)
  3. After the loop, assign ReturnValue := TB.ToText()

Results After the Fix

After applying the TextBuilder optimization, the results were dramatic:

Data Size Before Fix (ms) After Fix (ms) Improvement
20,000 ~1,500 ~100 ~15x faster
40,000 ~6,000 ~200 ~30x faster
60,000 ~12,000 ~300 ~40x faster
200,000 ~133,000 ~1,000 ~133x faster

Not only is the code dramatically faster, but the performance now scales linearly with input size — exactly what you'd expect from a well-optimized algorithm. The 200,000-character test went from over two minutes down to roughly one second.

Erik notes that it's still hard to compete with the built-in Base64 Convert codeunit from Microsoft, which calls directly into .NET and can work natively with binary data. But the TextBuilder optimization eliminates the catastrophic performance degradation that made the pure-AL version unusable at scale.

Key Takeaway

If you're doing any kind of repeated string concatenation in AL — whether it's building XML, JSON, CSV, Base64, or any other text format — always use TextBuilder instead of the += operator on text variables. The change requires just a few lines of code but can yield performance improvements of 100x or more on large inputs. String concatenation in a loop is one of the most common and costly performance mistakes in Business Central development, and TextBuilder is the simple, built-in solution.