C# String Performance Considerations
In the era of blazing fast compute and memory, it’s easy for the performance characteristics of System objects to feel like a thing of the past.
When 50+ Azure App Service instances restart simultaneously and all reach for the same Key Vault, things break. This post walks through how Tilt replaced its secrets management with TiltSecret — a lazy-loading, strongly-typed, pipeline-validated system that eliminated Key Vault throttling, prevented deployment failures, and gave engineers a frictionless local development experience. If you run .NET at scale on Azure, this one’s for you.
At Tilt, we follow the Modern Monolith Architecture pattern that we host on Azure, leveraging App Services to run our API and Webjobs with individual instances across these scaling to 100+ at a time. All of these instances need access to the same secrets stored in Azure Key Vault—API keys for third-party services, database connection strings, and other sensitive configuration. For years, we managed these secrets using Azure App Service’s built-in Key Vault integration, but as we scaled, this approach began to show its cracks. This is the story of how we built TiltSecret to solve our secrets management challenges.
Before TiltSecret, our secrets management looked like this:
{
"name": "Socure:ApiKey",
"value": "@Microsoft.KeyVault(VaultName=test-keyvault;SecretName=TestService--ApiKey)"
}
public class SocureConfiguration
{
public string ApiKey { get; set; } // Populated from IConfiguration["Socure:ApiKey"]
}
This approach seemed reasonable at first. Azure handled the Key Vault integration automatically, and we could use the familiar IConfiguration pattern throughout our codebase.
To understand why this became problematic, you need to understand our architecture:
As our system grew, we encountered three categories of problems:
These were architectural decisions that, while beneficial overall, created challenges for secrets management:
These were the real pain points that forced us to find a better solution:
This was the big one. Azure Key Vault has rate limits to prevent abuse:
When 50+ App Service instances all restart simultaneously during a deployment, they all try to load secrets from Key Vault at the same time. With 200+ secrets per instance being loaded, we were easily hitting these limits.
The impact: Some instances would fail to start because they couldn’t retrieve their secrets. This was transient—eventually the throttling would clear and instances would succeed—but it meant deployments were unreliable and slow.
We initially considered using Microsoft’s built-in AzureKeyVaultConfigurationProvider. However, this approach had a fatal flaw related to how .NET builds its configuration.
When an IHost builds its configuration, it enumerates all configuration sources during startup. If you add Key Vault as a configuration source, .NET will enumerate all secrets in the vault to build the configuration object.
With our single Key Vault containing all secrets for the entire application, this meant:
The impact: Using the built-in provider wasn’t an option—it solved the problem of having secrets closer to code but left the throttling issue in place
If a secret didn’t exist in Key Vault when an instance started, the application would fail to start. This sounds obvious, but the consequences were severe:
Beyond the technical issues, we had workflow problems:
appsettings.jsonIn October 2024, we completed the implementation of TiltSecret. This system addresses all the problems above while maintaining the security and convenience we needed.
All secrets are defined as static readonly fields in a central TiltSecrets class:
public static class TiltSecrets
{
public static readonly EmpowerSecret AzureWebJobsStorage = new(new("ConnectionStrings--AzureWebJobsStorage"));
public static readonly EmpowerSecret AzureWebJobsServiceBus = new(new("ConnectionStrings--AzureWebJobsServiceBus"));
public static class ExampleThirdPartyService
{
public static class Shared
{
public static readonly TiltSecret ApiKey = new(new("ExampleThirdPartyService--ApiKey"));
public static readonly TiltSecret SdkKey = new(new("ExampleThirdPartyService--SdkKey"));
public static readonly TiltSecret WebhookSecret = new(new("ExampleThirdPartyService--WebhookSecret"));
}
}
// ... many, many more
}
This gives us:
The TiltSecret class implements lazy loading:
public record TiltSecret
{
private string _retrievedSecretValue;
public TiltSecretKey Key { get; }
public virtual string RetrieveSecretValueFromKeyVault()
=> _retrievedSecretValue ??= TiltAzureKeyVaultSecretsManager.GetSecret(Key);
}
The TiltAzureKeyVaultSecretsManager handles the actual Key Vault interaction:
public static class TiltAzureKeyVaultSecretsManager
{
private static readonly ConcurrentDictionary<TiltSecretKey, string> _cachedSecrets = new();
private static readonly NamedLockingMonitor _locker = new();
public static string GetSecret(TiltSecretKey key)
{
lock (_locker[key.Value])
{
if (_cachedSecrets.TryGetValue(key, out var secretValue))
{
return secretValue;
}
var secretResponse = SecretClient.GetSecret(key);
_cachedSecrets[key] = secretValue = secretResponse.Value?.Value;
return secretValue;
}
}
}
Key features:
RetrieveSecretValueFromKeyVault() is called
Secrets are now accessed explicitly in configuration classes:
public class ExampleThirdPartyServiceConfiguration
{
public string IdPlusBaseUrl { get; set; }
public string DocumentUploadBaseUrl { get; set; }
public string ApiKey => TiltSecrets.ExampleThirdPartyService.Shared.ApiKey.RetrieveSecretValueFromKeyVault();
public string SdkKey => TiltSecrets.ExampleThirdPartyService.Shared.SdkKey.RetrieveSecretValueFromKeyVault();
public string WebhookSecret => TiltSecrets.ExampleThirdPartyService.Shared.WebhookSecret.RetrieveSecretValueFromKeyVault();
}
This pattern means:
ExampleThirdPartyService, it never loads these secretsSome Azure services (like Azure Functions triggers) require secrets to be available through IConfiguration. For these cases, we created a custom configuration provider:
public static class TiltSecretsForConfiguration
{
public static readonly HashSet<TiltSecret> AllSecrets =
[
TiltSecrets.AzureWebJobsStorage,
TiltSecrets.AzureWebJobsServiceBus,
TiltSecrets.DataStorageAccount,
// Only secrets that MUST be in IConfiguration
];
}
This provider is added to the configuration builder:
builder
.AddJsonFile("appsettings.json")
.AddJsonFile($"appsettings.{environment}.json")
.AddTiltSecretsConfiguration(TiltSecretsForConfiguration.AllSecrets)
.AddEnvironmentVariables();
The custom TiltSecretConfigurationProvider overrides TryGet to lazily fetch secrets:
public override bool TryGet(string key, out string value)
{
if (_secrets.TryGetValue(key, out var secret))
{
value = secret.RetrieveSecretValueFromKeyVault();
return true;
}
value = null;
return false;
}
This means:
TiltSecretsForConfiguration are available through IConfigurationFor local development, engineers can override secrets without needing Key Vault access:
// appsettings.debug.Development.json
{
"AzureKeyVault": {
"UseSecretOverrideValues": true
},
"Socure": {
"ApiKey": "test-api-key-for-local-dev"
}
}
The TiltAzureKeyVaultSecretsManager checks for overrides first:
public static string GetSecret(TiltSecretKey key)
{
if (_useSecretOverrideValues)
{
var localOverrideValue = GetOverrideValue(key);
if (!string.IsNullOrEmpty(localOverrideValue))
{
return localOverrideValue;
}
}
// Fall back to Key Vault...
}
This enables:
To prevent missing secrets from causing deployment failures, we built a validation tool that runs in our CI/CD pipeline:
public class ValidateSecretsExistRunner
{
internal async Task<IList<SecretValidationResult>> ValidateSecretsForEnvironment(string environment)
{
var secretKeysInCode = GetTiltSecretsDefinedInCode();
var secretClient = _secretClientFactory.CreateSecretClient(keyVaultName);
var keyVaultSecretKeys = await secretClient
.GetPropertiesOfSecretsAsync()
.Select(sk => sk.Name)
.ToListAsync();
var missingSecrets = secretKeysInCode
.Except(keyVaultSecretKeys, StringComparer.OrdinalIgnoreCase)
.ToList();
if (missingSecrets.Count > 0)
{
return new SecretValidationResult { WasValidationSuccessful = false };
}
return new SecretValidationResult { WasValidationSuccessful = true };
}
}
This runs before deployment and:
TiltSecret fields in the compiled codeSince implementing TiltSecret, we’ve seen significant improvements:
Building TiltSecret was a journey from a simple, built-in solution that didn’t scale to a custom implementation that fits our specific needs. The key insights were:
If you’re running a .NET application on Azure with similar scale challenges, we hope our experience helps you avoid some of the pitfalls we encountered. The modern monolith architecture has many benefits, but it requires thoughtful solutions to problems like secrets management.
Have questions about our implementation or want to discuss secrets management strategies? Reach out to our engineering team!
Experienced Software Engineer at Tilt with a demonstrated history working across diverse technology domains. Holds a U.S. patent for “Design and Systems Architecture for Internet of Things.” Washington State University alum, TEALS teaching assistant, and Mentors in Tech mentor. Passionate about building scalable systems and solving complex infrastructure challenges.

In the era of blazing fast compute and memory, it’s easy for the performance characteristics of System objects to feel like a thing of the past.
At ~~Empower~~ Tilt, a data-driven fintech startup, our lifeblood is understanding our users and how they interact with our products.
A seemingly simple FK constraint drop turned into a high-stakes locking issue that threatened database stability.
More in Engineering