| diff --git a/LibMatrix/Extensions/CanonicalJsonSerializer.cs b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
new file mode 100644
index 0000000..a6fbcf4
--- /dev/null
+++ b/LibMatrix/Extensions/CanonicalJsonSerializer.cs
@@ -0,0 +1,91 @@
+using System.Collections.Frozen;
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using System.Text.Unicode;
+using ArcaneLibs.Extensions;
+
+namespace LibMatrix.Extensions;
+
+public static class CanonicalJsonSerializer {
+    // TODO: Alphabetise dictionaries
+    private static JsonSerializerOptions _options => new() {
+        WriteIndented = false,
+        Encoder = UnicodeJsonEncoder.Singleton,
+    };
+
+    private static readonly FrozenSet<PropertyInfo> JsonSerializerOptionsProperties = typeof(JsonSerializerOptions)
+        .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+        .Where(x => x.SetMethod != null && x.GetMethod != null)
+        .ToFrozenSet();
+
+    private static JsonSerializerOptions MergeOptions(JsonSerializerOptions? inputOptions) {
+        var newOptions = _options;
+        if (inputOptions == null)
+            return newOptions;
+        
+        foreach (var property in JsonSerializerOptionsProperties) {
+            if(property.Name == nameof(JsonSerializerOptions.Encoder))
+                continue;
+            if (property.Name == nameof(JsonSerializerOptions.WriteIndented))
+                continue;
+                
+            var value = property.GetValue(inputOptions);
+            // if (value == null)
+                // continue;
+            property.SetValue(newOptions, value);
+        }
+
+        return newOptions;
+    }
+
+#region STJ API
+
+    public static String Serialize<TValue>(TValue value, JsonSerializerOptions? options = null) {
+        var newOptions = MergeOptions(options);
+
+        return System.Text.Json.JsonSerializer.SerializeToNode(value, options) // We want to allow passing custom converters for eg. double/float -> string here...
+            .SortProperties()!
+            .CanonicalizeNumbers()!
+            .ToJsonString(newOptions);
+        
+            
+        // System.Text.Json.JsonSerializer.SerializeToNode(System.Text.Json.JsonSerializer.Deserialize<dynamic>("{\n    \"a\": -0,\n    \"b\": 1e10\n}")).ToJsonString();
+        
+    }
+
+    public static String Serialize(object value, Type inputType, JsonSerializerOptions? options = null) => JsonSerializer.Serialize(value, inputType, _options);
+    // public static String Serialize<TValue>(TValue value, JsonTypeInfo<TValue> jsonTypeInfo) => JsonSerializer.Serialize(value, jsonTypeInfo, _options);
+    // public static String Serialize(Object value, JsonTypeInfo jsonTypeInfo) 
+
+#endregion
+
+    private static partial class JsonExtensions {
+        public static Action<JsonTypeInfo> AlphabetizeProperties(Type type) {
+            return typeInfo => {
+                if (typeInfo.Kind != JsonTypeInfoKind.Object || !type.IsAssignableFrom(typeInfo.Type))
+                    return;
+                AlphabetizeProperties()(typeInfo);
+            };
+        }
+
+        public static Action<JsonTypeInfo> AlphabetizeProperties() {
+            return static typeInfo => {
+                if (typeInfo.Kind == JsonTypeInfoKind.Dictionary) { }
+
+                if (typeInfo.Kind != JsonTypeInfoKind.Object)
+                    return;
+                var properties = typeInfo.Properties.OrderBy(p => p.Name, StringComparer.Ordinal).ToList();
+                typeInfo.Properties.Clear();
+                for (int i = 0; i < properties.Count; i++) {
+                    properties[i].Order = i;
+                    typeInfo.Properties.Add(properties[i]);
+                }
+            };
+        }
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Extensions/EnumerableExtensions.cs b/LibMatrix/Extensions/EnumerableExtensions.cs
 index 42d9491..ace2c0c 100644
--- a/LibMatrix/Extensions/EnumerableExtensions.cs
+++ b/LibMatrix/Extensions/EnumerableExtensions.cs
@@ -1,29 +1,91 @@
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
 namespace LibMatrix.Extensions;
 
 public static class EnumerableExtensions {
+    public static int insertions = 0;
+    public static int replacements = 0;
+
     public static void MergeStateEventLists(this IList<StateEvent> oldState, IList<StateEvent> newState) {
-        foreach (var stateEvent in newState) {
-            var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
-            if (old is null) {
-                oldState.Add(stateEvent);
-                continue;
+        // foreach (var stateEvent in newState) {
+        //     var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
+        //     if (old is null) {
+        //         oldState.Add(stateEvent);
+        //         continue;
+        //     }
+        //
+        //     oldState.Remove(old);
+        //     oldState.Add(stateEvent);
+        // }
+
+        foreach (var e in newState) {
+            switch (FindIndex(e)) {
+                case -1:
+                    oldState.Add(e);
+                    break;
+                case var index:
+                    oldState[index] = e;
+                    break;
             }
+        }
 
-            oldState.Remove(old);
-            oldState.Add(stateEvent);
+        int FindIndex(StateEvent needle) {
+            for (int i = 0; i < oldState.Count; i++) {
+                var old = oldState[i];
+                if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+                    return i;
+            }
+
+            return -1;
         }
     }
 
     public static void MergeStateEventLists(this IList<StateEventResponse> oldState, IList<StateEventResponse> newState) {
-        foreach (var stateEvent in newState) {
-            var old = oldState.FirstOrDefault(x => x.Type == stateEvent.Type && x.StateKey == stateEvent.StateKey);
-            if (old is null) {
-                oldState.Add(stateEvent);
-                continue;
+        foreach (var e in newState) {
+            switch (FindIndex(e)) {
+                case -1:
+                    oldState.Add(e);
+                    break;
+                case var index:
+                    oldState[index] = e;
+                    break;
+            }
+        }
+
+        int FindIndex(StateEventResponse needle) {
+            for (int i = 0; i < oldState.Count; i++) {
+                var old = oldState[i];
+                if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+                    return i;
+            }
+
+            return -1;
+        }
+    }
+
+    public static void MergeStateEventLists(this List<StateEventResponse> oldState, List<StateEventResponse> newState) {
+        foreach (var e in newState) {
+            switch (FindIndex(e)) {
+                case -1:
+                    oldState.Add(e);
+                    insertions++;
+                    break;
+                case var index:
+                    oldState[index] = e;
+                    replacements++;
+                    break;
+            }
+        }
+
+        int FindIndex(StateEventResponse needle) {
+            for (int i = 0; i < oldState.Count; i++) {
+                var old = oldState[i];
+                if (old.Type == needle.Type && old.StateKey == needle.StateKey)
+                    return i;
             }
 
-            oldState.Remove(old);
-            oldState.Add(stateEvent);
+            return -1;
         }
     }
 }
\ No newline at end of file
diff --git a/LibMatrix/Extensions/MatrixHttpClient.Single.cs b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
 index 3c8aea4..0e6d467 100644
--- a/LibMatrix/Extensions/MatrixHttpClient.Single.cs
+++ b/LibMatrix/Extensions/MatrixHttpClient.Single.cs
@@ -2,6 +2,7 @@
 // #define SYNC_HTTPCLIENT   // Only allow one request as a time, for debugging
 using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
+using System.Net;
 using System.Net.Http.Headers;
 using System.Reflection;
 using System.Security.Cryptography.X509Certificates;
@@ -14,7 +15,7 @@ using ArcaneLibs.Extensions;
 namespace LibMatrix.Extensions;
 
 #if SINGLE_HTTPCLIENT
-// TODO: Add URI wrapper for 
+// TODO: Add URI wrapper for
 public class MatrixHttpClient {
     private static readonly HttpClient Client;
 
@@ -27,7 +28,7 @@ public class MatrixHttpClient {
             };
             Client = new HttpClient(handler) {
                 DefaultRequestVersion = new Version(3, 0),
-                Timeout = TimeSpan.FromHours(1)
+                Timeout = TimeSpan.FromDays(1)
             };
         }
         catch (PlatformNotSupportedException e) {
@@ -50,6 +51,7 @@ public class MatrixHttpClient {
     internal SemaphoreSlim _rateLimitSemaphore { get; } = new(1, 1);
 #endif
 
+    private const bool LogRequests = true;
     public Dictionary<string, string> AdditionalQueryParameters { get; set; } = new();
 
     public Uri? BaseAddress { get; set; }
@@ -70,21 +72,31 @@ public class MatrixHttpClient {
 
     public async Task<HttpResponseMessage> SendUnhandledAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
         if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
-        // if (!request.RequestUri.IsAbsoluteUri) 
-            request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
+        // if (!request.RequestUri.IsAbsoluteUri)
+        request.RequestUri = request.RequestUri.EnsureAbsolute(BaseAddress!);
         var swWait = Stopwatch.StartNew();
 #if SYNC_HTTPCLIENT
         await _rateLimitSemaphore.WaitAsync(cancellationToken);
 #endif
+
+        Console.WriteLine($"Sending {request.Method} {BaseAddress}{request.RequestUri} ({Util.BytesToString(request.GetContentLength())})");
+
+        if (request.RequestUri is null) throw new NullReferenceException("RequestUri is null");
+        if (!request.RequestUri.IsAbsoluteUri) request.RequestUri = new Uri(BaseAddress, request.RequestUri);
         swWait.Stop();
         var swExec = Stopwatch.StartNew();
-       
+
         foreach (var (key, value) in AdditionalQueryParameters) request.RequestUri = request.RequestUri.AddQuery(key, value);
-        foreach (var (key, value) in DefaultRequestHeaders) request.Headers.Add(key, value);
+        foreach (var (key, value) in DefaultRequestHeaders) {
+            if (request.Headers.Contains(key)) continue;
+            request.Headers.Add(key, value);
+        }
+
         request.Options.Set(new HttpRequestOptionsKey<bool>("WebAssemblyEnableStreamingResponse"), true);
 
-        Console.WriteLine("Sending " + request.Summarise(includeHeaders:true, includeQuery: true, includeContentIfText: true));
-        
+        if (LogRequests)
+            Console.WriteLine("Sending " + request.Summarise(includeHeaders: true, includeQuery: true, includeContentIfText: true, hideHeaders: ["Accept"]));
+
         HttpResponseMessage? responseMessage;
         try {
             responseMessage = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -101,19 +113,25 @@ public class MatrixHttpClient {
 #endif
 
         // Console.WriteLine($"Sending {request.Method} {request.RequestUri} ({Util.BytesToString(request.Content?.Headers.ContentLength ?? 0)}) -> {(int)responseMessage.StatusCode} {responseMessage.StatusCode} ({Util.BytesToString(responseMessage.GetContentLength())}, WAIT={swWait.ElapsedMilliseconds}ms, EXEC={swExec.ElapsedMilliseconds}ms)");
-        Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
-            "Server",
-            "Date",
-            "Transfer-Encoding",
-            "Connection",
-            "Vary",
-            "Content-Length",
-            "Access-Control-Allow-Origin",
-            "Access-Control-Allow-Methods",
-            "Access-Control-Allow-Headers",
-            "Access-Control-Expose-Headers",
-            "Cache-Control"    
-        ]));
+        if (LogRequests)
+            Console.WriteLine("Received " + responseMessage.Summarise(includeHeaders: true, includeContentIfText: false, hideHeaders: [
+                "Server",
+                "Date",
+                "Transfer-Encoding",
+                "Connection",
+                "Vary",
+                "Content-Length",
+                "Access-Control-Allow-Origin",
+                "Access-Control-Allow-Methods",
+                "Access-Control-Allow-Headers",
+                "Access-Control-Expose-Headers",
+                "Cache-Control",
+                "Cross-Origin-Resource-Policy",
+                "X-Content-Security-Policy",
+                "Referrer-Policy",
+                "X-Robots-Tag",
+                "Content-Security-Policy"
+            ]));
 
         return responseMessage;
     }
@@ -122,6 +140,12 @@ public class MatrixHttpClient {
         var responseMessage = await SendUnhandledAsync(request, cancellationToken);
         if (responseMessage.IsSuccessStatusCode) return responseMessage;
 
+        //retry on gateway timeout
+        if (responseMessage.StatusCode == HttpStatusCode.GatewayTimeout) {
+            request.ResetSendStatus();
+            return await SendAsync(request, cancellationToken);
+        }
+
         //error handling
         var content = await responseMessage.Content.ReadAsStringAsync(cancellationToken);
         if (content.Length == 0)
@@ -248,4 +272,4 @@ public class MatrixHttpClient {
         await SendAsync(request);
     }
 }
-#endif
\ No newline at end of file
+#endif
diff --git a/LibMatrix/Extensions/UnicodeJsonEncoder.cs b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
new file mode 100644
 index 0000000..ae58263
--- /dev/null
+++ b/LibMatrix/Extensions/UnicodeJsonEncoder.cs
@@ -0,0 +1,173 @@
+// LibMatrix: File sourced from https://github.com/dotnet/runtime/pull/87147/files under the MIT license.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+using System.Text.Encodings.Web;
+
+namespace LibMatrix.Extensions;
+
+internal sealed class UnicodeJsonEncoder : JavaScriptEncoder
+{
+    internal static readonly UnicodeJsonEncoder Singleton = new UnicodeJsonEncoder();
+
+    private readonly bool _preferHexEscape;
+    private readonly bool _preferUppercase;
+
+    public UnicodeJsonEncoder()
+        : this(preferHexEscape: false, preferUppercase: false)
+    {
+    }
+
+    public UnicodeJsonEncoder(bool preferHexEscape, bool preferUppercase)
+    {
+        _preferHexEscape = preferHexEscape;
+        _preferUppercase = preferUppercase;
+    }
+
+    public override int MaxOutputCharactersPerInputCharacter => 6; // "\uXXXX" for a single char ("\uXXXX\uYYYY" [12 chars] for supplementary scalar value)
+
+    public override unsafe int FindFirstCharacterToEncode(char* text, int textLength)
+    {
+        for (int index = 0; index < textLength; ++index)
+        {
+            char value = text[index];
+
+            if (NeedsEncoding(value))
+            {
+                return index;
+            }
+        }
+
+        return -1;
+    }
+
+    public override unsafe bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten)
+    {
+        bool encode = WillEncode(unicodeScalar);
+
+        if (!encode)
+        {
+            Span<char> span = new Span<char>(buffer, bufferLength);
+            int spanWritten;
+            bool succeeded = new Rune(unicodeScalar).TryEncodeToUtf16(span, out spanWritten);
+            numberOfCharactersWritten = spanWritten;
+            return succeeded;
+        }
+
+        if (!_preferHexEscape && unicodeScalar <= char.MaxValue && HasTwoCharacterEscape((char)unicodeScalar))
+        {
+            if (bufferLength < 2)
+            {
+                numberOfCharactersWritten = 0;
+                return false;
+            }
+
+            buffer[0] = '\\';
+            buffer[1] = GetTwoCharacterEscapeSuffix((char)unicodeScalar);
+            numberOfCharactersWritten = 2;
+            return true;
+        }
+        else
+        {
+            if (bufferLength < 6)
+            {
+                numberOfCharactersWritten = 0;
+                return false;
+            }
+
+            buffer[0] = '\\';
+            buffer[1] = 'u';
+            buffer[2] = '0';
+            buffer[3] = '0';
+            buffer[4] = ToHexDigit((unicodeScalar & 0xf0) >> 4, _preferUppercase);
+            buffer[5] = ToHexDigit(unicodeScalar & 0xf, _preferUppercase);
+            numberOfCharactersWritten = 6;
+            return true;
+        }
+    }
+
+    public override bool WillEncode(int unicodeScalar)
+    {
+        if (unicodeScalar > char.MaxValue)
+        {
+            return false;
+        }
+
+        return NeedsEncoding((char)unicodeScalar);
+    }
+
+    // https://datatracker.ietf.org/doc/html/rfc8259#section-7
+    private static bool NeedsEncoding(char value)
+    {
+        if (value == '"' || value == '\\')
+        {
+            return true;
+        }
+
+        return value <= '\u001f';
+    }
+
+    private static bool HasTwoCharacterEscape(char value)
+    {
+        // RFC 8259, Section 7, "char = " BNF
+        switch (value)
+        {
+            case '"':
+            case '\\':
+            case '/':
+            case '\b':
+            case '\f':
+            case '\n':
+            case '\r':
+            case '\t':
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    private static char GetTwoCharacterEscapeSuffix(char value)
+    {
+        // RFC 8259, Section 7, "char = " BNF
+        switch (value)
+        {
+            case '"':
+                return '"';
+            case '\\':
+                return '\\';
+            case '/':
+                return '/';
+            case '\b':
+                return 'b';
+            case '\f':
+                return 'f';
+            case '\n':
+                return 'n';
+            case '\r':
+                return 'r';
+            case '\t':
+                return 't';
+            default:
+                throw new ArgumentOutOfRangeException(nameof(value));
+        }
+    }
+
+    private static char ToHexDigit(int value, bool uppercase)
+    {
+        if (value > 0xf)
+        {
+            throw new ArgumentOutOfRangeException(nameof(value));
+        }
+
+        if (value < 10)
+        {
+            return (char)(value + '0');
+        }
+        else
+        {
+            return (char)(value - 0xa + (uppercase ? 'A' : 'a'));
+        }
+    }
+}
\ No newline at end of file
 |