Rust: Beware of Escape Sequences \n
By: Dominic E.
Tags: Deserialization, Rust, JavaScript, Web Development, Json

So good so far, but are those escape sequences really a problem? Yes and no.

It’s a problem because we have to use String and cannot be smarty pants and store &str slice references to the original text in our struct, which would be ideal. Because, after all, serde advertises zero-copy deserialization, which means that we’d just borrow the string or the bytes. More importantly, this avoids allocating memory for every field and avoids copying data from the input over to the newly allocated fields.

However, this won’t work if our strings contain escape sequences for precisely the reasons we discussed above. What’s in memory is different from the original JSON string, and hence we need to copy the string and own the underlying memory to mutate it. It has to be mutated because we have to get rid of those escape sequences and unescape them. Luckily, serde does for us.

Doesn’t sound that bad if it’s all taken care of for us anyway, right? Not really. It’s undoubtedly a computational overhead of copying over all the data, but what’s more, is unescaping the escape sequences. For small strings this won’t be really an issue, but if you deal with MBs of data, this can become very noticeable and now there’s a bottleneck. Dang.

This is something to keep in mind. I want you to walk away from this blog post with an awareness of this “problem” and one or two possible solutions. For what it’s worth, this was a real issue for me and it took me a while to get to the bottom of this. I wish I had such blog post back then. I suppose that’s why we are here 🤓.

So… how do we fix this?

The Solution(s)

Now that we know what we are dealing with, it’s time to look at the solution. In fact, there is more than one solution, but we’ll look at two in particular.

We already know that using string slices won’t work, and the compiler will simply yell at us. We don’t want to use String , because of the overhead of memory allocation and the additional compute for unescaping the escaped control sequences.

One solution would be to use smart pointers, in particular Cow .

Smart Pointer: Cow 🐮

Cow is one of Rust’s smart pointers, which provides clone-on-write functionality. This means it allows immutable access to borrowed data but clones the data if needed, that is, if mutation or ownership is required. This is pretty cool, right?

With that in mind, we could redesign our Rust data types as follows:

use std::borrow::Cow; #[derive(Serialize, Deserialize)]

struct Snippet<'a> {

#[serde(borrow)]

description: Cow<'a, str>, public: bool, files: HashMap< &'a str >, File<'a>>,

} #[derive(Serialize, Deserialize)]

struct File<'a> {

name: &'a str, #[serde(borrow)]

content: Cow<'a, str>,

}

Notice how we now use Cow<'a, str> instead of String for the description and content . Other fields like name will likely not contain newlines.

Any fields using Cow are now dynamic and can either be &str or String . It’s also necessary to use #[serde(borrow] in combination with Cow because type like &str are implicitly borrowed. Other types of fields need to opt in to borrowing by using the attribute. By default, using Cow with Deserialize will always own the value.

Check out this playground, which demonstrates that, if you’re not using the [serde(borrow)] attribute, the data is owned even though the content does not contain any special characters. Play around with the code and the content, and add the attribute to see how the output changes.

Because Cow works with general borrowed data, we have to be explicit about the lifetime of the data. The lifetime of the strings is equal to the lifetime of the Snippet .

It’s definitely a solution, and it’s better than using String because it would not copy data where mutation is not required, e.g., if there are no escape sequences in the data. Instead of cloning everything by default, it will lazily clone and create an owned value only if needed. This could potentially save computation. However, it’s rather unlikely that the data does not contain escape sequences.

So what’s better?

Binary Formats

A better solution may be to simply not use JSON for data exchange between the client and the server. I know, JSON is very convenient and easy to use, but if performance matters, then maybe it’s time to rethink the format in which we exchange data.

We have already seen that what’s in memory is different from the actual string. Again, that’s because control sequences get stored as a single byte rather than escaping it and storing multiple characters.

What if we could send bytes directly?

Yes! That’s absolutely possible and also reasonable, and we now enter the territory of binary formats.

There are a variety of binary formats, including:

FlatBuffers

Protocol Buffers (Protobuf)

CBOR

BSON

MessagePack

…and more. There are always pros and cons when choosing between a format for data exchange. JSON is for sure the de facto solution on the web, but as we have seen, it has its drawbacks. As always, it really depends on the project, and there are still tradeoffs. For my specific use case, I ended up using a binary format for several reasons:

Deserialization was much faster, and I avoided the computational overhead for dealing with escape sequences and memory allocation. After some profiling, I saw a 3x improvement over serde_json . A huge factor was the zero-copy value decoding which was now possible.

improvement over . A huge factor was the zero-copy value decoding which was now possible. Binary formats tend to be much more compact and therefore save bandwidth and reduce the request time.

There are, of course, also downsides. For example, the data has to be encoded into binary somewhere, and for us, that’s on the client side. This means we are limited to use JavaScript or WebAssembly if we are lucky. So there is some cost to pay after all. Whether that cost is higher or smaller is for you to find out. An in-depth comparison is out of scope for this blog post. However, in my case, the advantages outweighed the disadvantages. Mostly because deserialization was 3x faster. In fact, encoding also outperformed JSON.stringify by more than 2x.

Conclusion

This post discussed escape sequences’ impact when exchanging data between a client and a server. We have seen that zero-copy value decoding might not always work and that deserialization may entail unexpected computational overhead. That said, we want to be careful when designing our data model. We should be thoughtful when choosing data types because certain types work better in some instances than others. For example, Cow is fantastic when you want to avoid cloning data unless ownership is required. If that doesn’t cut it and you still experience performance issues to a certain degree, then maybe look into binary formats. It’s not always the best to default to JSON.

But keep mind:

Always measure! Premature optimizations are the root cause of all evil 😈.

I hope this blog post was helpful. If you have any questions, feel free to reach out to me on Twitter. I’d love to chat.