In this video I try to performance optimize a base64 module written by A.J. Kauffmann:
Find AJ’s stuff at:
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:
- Create a
TextBuildervariable - Call
Append()to add string fragments - 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:
- Declare a
TB: TextBuildervariable - Replace
ReturnValue += SomeValuewithTB.Append(SomeValue) - 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.