Blog

November 16, 2016

Framework Benchmarks Round 13

Round 13 of the ongoing Web Framework Benchmarks project is here! The project now features 230 framework implementations (of our JSON serialization test) and includes new entrants on platforms as diverse as Kotlin and Qt. Yes, that Qt. We also congratulate the ASP.NET team for the most dramatic performance improvement we've ever seen, making ASP.NET Core a top performer.

View Round 13 resultsThe large filters panel on our results web site is a testament to the ever-broadening spectrum of options for web developers. What a great time to be building web apps! A great diversity of frameworks means there are likely many options that provide high-performance while meeting your language and productivity requirements.

Good fortunes

As the previous round—Round 12—was wrapping up, we were unfortunately rushed as the project’s physical hardware environment was being decommissioned. But good fortune was just around the corner, thanks to the lucky number 13!

New hardware and cloud environments

For Round 13, we have all new test environments, for both physical hardware and the virtualized public cloud.

Microsoft has provided the project with Azure credits, so starting with Round 13, the cloud environment is on Azure D3v2 instances. Previous rounds’ cloud tests were run on AWS.

Meanwhile, ServerCentral has provided the project a trio of physical servers in one of their development lab environments with 10 gigabit Ethernet. Starting with Round 13, the physical hardware environment is composed of a Dell R910 application server (4x 10-Core E7-4850 CPUs) and a Dell R420 database server (2x 4-Core E5-2406 CPUs).

We’d like to extend huge thanks to ServerCentral and Microsoft for generously supporting the project!

We recognize that as a result of these changes, Round 13 is not easy to directly compare to Round 12. Although changing the test environments was not intentional, it was necessary. We believe the results are still as valuable as ever. An upside of this environment diversity is visibility into the ways various frameworks and platforms work with the myriad variables of cores, clock speed, and virtualization technologies. For example, our new physical application server has twice as many HT cores as the previous environment, but the CPUs are older, so there is an interesting balance of higher concurrency but potentially lower throughput. In aggregate, the Round 13 results on physical hardware are generally lower due to the older CPUs, all else being equal.

Many fixes to long-broken tests

Along with the addition of new frameworks, Round 13 also marks a sizeable decrease in the number of existing framework tests that have failed to execute properly in previous rounds. This is largely the result of a considerable community effort over the past few months to identify and fix dozens of frameworks, some of which we haven’t been able to successfully test since 2014.

Continuous benchmarking

Round 13 is the first round conducted with what we’re calling Continuous Benchmarking. Continuous Benchmarking is the notion of setting up the test environment to automatically reset to a clean state, pull the latest from the source repository, prepare the environment, execute the test suite, deliver results, and repeat.

There are many benefits of Continuous Benchmarking. For example:

  • At any given time, we can grab the most recent results and mark them as a preview or final for an official Round. This should allow us to accelerate the delivery of Rounds.
  • With some additional work, we will be able to capture and share results as they are made available. This should give participants in the project much quicker insight into how their performance tuning efforts are playing out in our test environment. Think of it as continuous integration but for benchmark results. Our long-term goal is to provide a results viewer that plots performance results over time.
  • Any changes that break the test environment as a whole or a specific framework’s test implementation should be visible much earlier. Prior to Continuous Benchmarking, breaking changes were often not detected until a preview run.

Microsoft’s ASP.NET Core

We consider ourselves very fortunate that our project has received the attention that it has from the web framework community. It has become a source of a great pride for our team. Among every reaction and piece of feedback we’ve received, our very favorite kind is when a framework maintainer recognizes a performance deficiency highlighted by this project and then works to improve that performance. We love this because we think of it as a small way of improving performance of the whole web, and we are passionate about performance.

Round 13 is especially notable for us because we are honored that Microsoft has made it a priority to improve ASP.NET’s performance in these benchmarks, and in so doing, improve the performance of all applications built on ASP.NET.

Thanks to Microsoft’s herculean performance tuning effort, ASP.NET—in the new cross-platform friendly form of ASP.NET Core—is now a top performer in our Plaintext test, making it among the fastest platforms at the fundamentals of web request routing. The degree of improvement is absolutely astonishing, going from 2,120 requests per second on Mono in Round 11 to 1,822,366 requests per second on ASP.NET Core in Round 13. That’s an approximately 85,900% improvement, and that doesn’t even account for Round 11’s hardware being faster than our new hardware. That is not a typo, it's 859 times faster! We believe this to be the most significant performance improvement that this project has ever seen.

By delivering cross-platform performance alongside their development toolset, Microsoft has made C# and ASP.NET one of the most interesting web development platforms available. We have a brief message to those developers who have avoided Microsoft’s web stack thinking it’s “slow” or that it’s for Windows only: ASP.NET Core is now wicked sick fast at the fundamentals and is improving in our other tests. Oh, and of course we’re running it on Linux. You may be thinking about the Microsoft of 10 years ago.

The best part, in our opinion, is that Microsoft is making performance a long-term priority. There is room to improve on our other more complex tests such as JSON serialization and Fortunes (which exercises database connectivity, data structures, encoding of unsafe text, and templating). Microsoft is taking on those challenges and will continue to improve the performance of its platform.

Our Plaintext test has historically been a playground for the ultra-fast Netty platform and several lesser-known/exotic platforms. (To be clear, there is nothing wrong with being exotic! We love them too!) Microsoft’s tuning work has brought a mainstream platform into the frontrunners. That achievement stands on its own. We congratulate the Microsoft .NET team for a massive performance improvement and for making ASP.NET Core a mainstream option that has the performance characteristics of an acutely-tuned fringe platform. It’s like an F1 car that anyone can drive. We should all be so lucky.

I want to combine the elements of multiple Stream instances into a single Stream. What's the best way to do this?

This article compares a few different solutions.

Stream.concat(a, b)

The JDK provides Stream.concat(a, b) for concatenating two streams.

void exampleConcatTwo() {
  Stream<String> a = Stream.of("one", "two");
  Stream<String> b = Stream.of("three", "four");
  Stream<String> out = Stream.concat(a, b);
  out.forEach(System.out::println);
  // Output:
  // one
  // two
  // three
  // four
}

What if we have more than two streams?

We could use Stream.concat(a, b) multiple times. With three streams we could write Stream.concat(Stream.concat(a, b), c).

To me that approach is depressing at three streams, and it rapidly gets worse as we add more streams.

Reduce

Alternatively, we can use reduce to perform the multiple incantations of Stream.concat(a, b) for us. The code adapts elegantly to handle any number of input streams.

void exampleReduce() {
  Stream<String> a = Stream.of("one", "two");
  Stream<String> b = Stream.of("three", "four");
  Stream<String> c = Stream.of("five", "six");
  Stream<String> out = Stream.of(a, b, c)
      .reduce(Stream::concat)
      .orElseGet(Stream::empty);
  out.forEach(System.out::println);
  // Output:
  // one
  // two
  // three
  // four
  // five
  // six
}

Be careful using this pattern! Note the warning in the documentation of Stream.concat(a, b):

Use caution when constructing streams from repeated concatenation. Accessing an element of a deeply concatenated stream can result in deep call chains, or even StackOverflowError.

It takes quite a few input streams to trigger this problem, but it is trivial to demonstrate:

void exampleStackOverflow() {
  List<Stream<String>> inputs = new AbstractList<Stream<String>>() {
    @Override
    public Stream<String> get(int index) {
      return Stream.of("one", "two");
    }

    @Override
    public int size() {
      return 1_000_000; // try changing this number
    }
  };
  Stream<String> out = inputs.stream()
      .reduce(Stream::concat)
      .orElseGet(Stream::empty);
  long count = out.count(); // probably throws
  System.out.println("count: " + count); // probably never reached
}

On my workstation, this method throws StackOverflowError after several seconds of churning.

What's going on here?

We can think of the calls to Stream.concat(a, b) as forming a binary tree. At the root is the concatenation of all the input streams. At the leaves are the individual input streams. Let's look at the trees for up to five input streams as formed by our reduce operation.

Two streams:
concat(a,b)ab
Three streams:
concat(concat(a,b),c)concat(a,b)cab
Four streams:
concat(concat(concat(a,b),c),d)concat(concat(a,b),c)dconcat(a,b)cab
Five streams:
concat(concat(concat(concat(a,b),c),d),e)concat(concat(econcat(a,b),c),d)concat(concat(a,b),c)dconcat(a,b)cab

The trees are perfectly unbalanced! Each additional input stream adds one layer of depth to the tree and one layer of indirection to reach all the other streams. This can have a noticeable negative impact on performance. With enough layers of indirection we'll see a StackOverflowError.

Balance

If we're worried that we'll concatenate a large number of streams and run into the aforementioned problems, we can balance the tree. This is as if we're optimizing a O(n) algorithm into a O(logn) one. We won't totally eliminate the possibility of StackOverflowError, and there may be other approaches that perform even better, but this should be quite an improvement over the previous solution.

void exampleBalance() {
  Stream<String> a = Stream.of("one", "two");
  Stream<String> b = Stream.of("three", "four");
  Stream<String> c = Stream.of("five", "six");
  Stream<String> out = concat(a, b, c);
  out.forEach(System.out::println);
  // Output:
  // one
  // two
  // three
  // four
  // five
  // six
}

@SafeVarargs
static <T> Stream<T> concat(Stream<T>... in) {
  return concat(in, 0, in.length);
}

static <T> Stream<T> concat(Stream<T>[] in, int low, int high) {
  switch (high - low) {
    case 0: return Stream.empty();
    case 1: return in[low];
    default:
      int mid = (low + high) >>> 1;
      Stream<T> left = concat(in, low, mid);
      Stream<T> right = concat(in, mid, high);
      return Stream.concat(left, right);
  }
}

Flatmap

There is another way to concatenate streams that is built into the JDK, and it does not involve Stream.concat(a, b) at all. It is flatMap.

void exampleFlatMap() {
  Stream<String> a = Stream.of("one", "two");
  Stream<String> b = Stream.of("three", "four");
  Stream<String> c = Stream.of("five", "six");
  Stream<String> out = Stream.of(a, b, c).flatMap(s -> s);
  out.forEach(System.out::println);
  // Output:
  // one
  // two
  // three
  // four
  // five
  // six
}

This generally outperforms the solutions based on Stream.concat(a, b) when each input stream contains fewer than 32 elements. As we increase the element count past 32, flatMap performs comparatively worse and worse as the element count rises.

flatMap avoids the StackOverflowError issue but it comes with its own set of quirks. For example, it interacts poorly with infinite streams. Calling findAny on the concatenated stream may cause the program to enter an infinite loop, whereas the other solutions would terminate almost immediately.

void exampleInfiniteLoop() {
  Stream<String> a = Stream.generate(() -> "one");
  Stream<String> b = Stream.generate(() -> "two");
  Stream<String> c = Stream.generate(() -> "three");
  Stream<String> out = Stream.of(a, b, c).flatMap(s -> s);
  Optional<String> any = combined.findAny(); // infinite loop
  System.out.println(any); // never reached
}

(The infinite loop is an implementation detail. This could be fixed in the JDK without changing the contract of flatMap.)

Also, flatMap forces its input streams into sequential mode even if they were originally parallel. The outermost concatenated stream can still be made parallel, and we will be able to process elements from distinct input streams in parallel, but the elements of each individual input stream must all be processed sequentially.

Analysis

Let me share a few trends that I've noticed when dealing with streams and stream concatenation in general, having written a fair amount of code in Java 8 by now.

  • There have been maybe one dozen cases where I've needed to concatenate streams. That's not all that many, so no matter how good the solution is, it's not going to have much of an impact for me.
  • In all but one of those one dozen cases, I needed to concatenate exactly two streams, so Stream.concat(a, b) was sufficient.
  • In the remaining case, I needed to concatenate exactly three streams. I was not even close to the point where StackOverflowError would become an issue. Stream.concat(Stream.concat(a, b), c) would have worked just fine, although I went with flatMap because I felt that it was easier to read.
  • I have never needed to concatenate streams in performance-critical sections of code.
  • I use infinite streams very rarely. When I do use them, it is obvious in context that they are infinite. And so concatenating infinite streams together and then asking a question like findAny on the result is just not something that I would be tempted to do. That particular issue with flatMap seems like one that I'll never come across.
  • I use parallel streams very rarely. I think I've only used them twice in production code. It is almost never the case that going parallel improves performance, and even when it might improve performance, it is unlikely that processing them in the singleton ForkJoinPool.commonPool() is how I will want to manage that work. The issue with flatMap forcing the input streams to be sequential seems very unlikely to be a real problem for me.
  • Let's suppose that I do want to concatenate parallel streams and have them processed in parallel. If I have eight input streams on an eight core machine, and each stream has roughly the same number of elements, the fact that flatMap forces the individual streams to be sequential will not degrade performance for me at all. All eight cores will be fully utilized, each core processing one of the eight input streams. If I have seven input streams on that same machine, I will see only slightly degraded performance. With six, slightly more degraded, and so on.

What's the takeaway from all this? Here is my advice:

For two input streams, use:
Stream.concat(a, b)

For more than two input streams, use:
Stream.of(a, b, c, ...).flatMap(s -> s)

That solution is good enough...

Overboard

...but what if we're not satisfied with "good enough"? What if we want a solution that's really fast no matter the size and shape of the input and doesn't have any of the quirks of the other solutions?

It is a bit much to inline in a blog article, so take a look at StreamConcatenation.java for the source code.

This implementation is similar to Stream.concat(a, b) in that it uses a custom Spliterator, except this implementation handles any number of input streams.

It performs quite well. It does not outperform every other solution in every scenario (flatMap is generally better for very small input streams), but it never performs much worse and it scales nicely with the number and size of the input streams.

Benchmark

I wrote a JMH benchmark to compare the four solutions discussed in this article. The benchmark uses each solution to concatenate a variable number of input streams with a variable number of elements per stream, then iterates over the elements of the concatenated stream. Here is the raw JMH output from my workstation and a prettier visualization of the benchmark results.

July 5, 2016

Mangling JSON numbers

If we have a long (64-bit integer) that we serialize into JSON, we might be in trouble if JavaScript consumes that JSON. JavaScript has the equivalent of double (64-bit floating point) for its numbers, and double cannot represent the same set of numbers as long. If we are not careful, our long is mangled in transit.

Consider 253 + 1. We can store that number in a long but not a double. Above 253, double does not have the bits required to represent every integer, creating gaps between the integers it can represent. 253 + 1 is the first integer to fall in one of these gaps. We can store 253 or 253 + 2 in a double, but 253 + 1 does not fit.

If we store 253 + 1 in a long and that number is meant to be precise, then we should avoid encoding it as a JSON number and sending it to a JavaScript client. The instant that client invokes JSON.parse they are doomed — they see a different number.

The JSON format does not mandate a particular number precision, but the application code on either side usually does. See also: Re: [Json] Limitations on number size?

This problem only occurs with very large numbers. Perhaps all the numbers we use are safe. Are we actually mangling our numbers? Probably not...

...but will we know? Will anything blow up, or will our application be silently, subtly wrong?

I suspect that when this problem does occur, it goes undetected for longer than it should. In the remainder of this article, we examine potential improvements to our handling of long.

Failing fast

We can change the way we serialize long into JSON.

When we encounter a long, we can require that the number fits into a double without losing information. If no information would be lost, we serialize the long as usual and move on. If information would be lost, we throw an exception and cause serialization to fail. We detonate immediately at the source of the error rather than letting it propagate around, doing who knows what.

Here is a utility method that can be used for this purpose:

public static void verifyLongFitsInDouble(long x) {
  double result = x;
  if (x != (long) result || x == Long.MAX_VALUE) {
    throw new IllegalArgumentException("Overflow: " + x);
  }
}

This approach appeals to me because it is unobtrusive. The check can be made in one central location, no changes to our view classes or client-side code are required, and it only throws exceptions in the specific cases where our default behavior is wrong.

A number that should be safe

Consider the number 262, which spelled out in base ten is 4611686018427387904. This number fits in both a long and a double. It passes our verifyLongFitsInDouble check. Theoretically we can send it from a Java server to a JavaScript client via JSON and both sides see exactly the same number.

To convince ourselves that this number is safe, we examine various representations of this number in Java and JavaScript:

// In Java
long x = 1L << 62;
System.out.println(Long.toString(x));    // 4611686018427387904
System.out.println(Double.toString(x));  // 4.6116860184273879E18
// 100000000000000000000000000000000000000000000000000000000000000
System.out.println(Long.toString(x, 2)); 
  
// In JavaScript
var x = Math.pow(2, 62);
console.log(x.toString());               // 4611686018427388000
console.log(x.toExponential());          // 4.611686018427388e+18
console.log(x.toFixed());                // 4611686018427387904
// 100000000000000000000000000000000000000000000000000000000000000
console.log(x.toString(2));

The output of x.toString() in JavaScript is suspicious. Do we really have the right number? We do, but we print it lazily.

x.toString() is similar in spirit to x.toExponential() and Double.toString(double) from Java. These algorithms essentially print significant digits, from most significant to least, until the output is unambiguously closer to this floating point number than any other floating point number. (And that is true here. The next lowest floating point number is 262 - 512, the next highest is 262 + 1024, and 4611686018427388000 is closer to 262 than either of those two nearby numbers.) See also: ES6 specification for ToString(Number)

x.toFixed() and the base two string give us more confidence that we have the correct number.

Verifying our assumptions with code

If 262 really is a safe number, we should be able to send it from the server to the client and back again. To verify that this number survives a round trip, we create an HTTP server with two kinds of endpoints:

  • GET endpoints that serialize a Java object into a JSON string like {"x":number}, where the number is a known constant (262). The number and the JSON string are printed to stdout. The response is that JSON string.
  • POST endpoints that deserialize a client-provided JSON string like {"x":number} into a Java object. The number and JSON string are printed to stdout. We hope that the number printed here is the same as the known constant (262) used in our GET endpoints.

Any server-side web framework or HTTP server will do. We happen to use JAX-RS in our example code.

Behavior may differ between JSON (de)serialization libraries, so we test two:

In total the server provides four endpoints, each named after the JSON serialization library used by that endpoint:

GET   /gson
POST  /gson
GET   /jackson
POST  /jackson

In the JavaScript client, we:

  • Loop through each library-specific pair of GET/POST endpoints.
  • Make a request to the GET endpoint.
  • Use JSON.parse to deserialize the response text (a JSON string) into a JavaScript object.
  • Use JSON.stringify to serialize that JavaScript object back into a JSON string.
  • Print each of the following to the console:
    • the incoming JSON string
    • the number contained in the JavaScript object, using x.toString()
    • the number contained in the JavaScript object, using x.toFixed()
    • the outgoing JSON string
  • Make a request to the POST endpoint, providing the (re)serialized JSON string as the request body.

Here is the server-side Java code:

package test;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;

import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import java.io.IOException;

@Path("/")
public final class JsonResource {

  public static final class Payload {
    public long x;
  }

  private static final long EXPECTED_NUMBER = 1L << 62;

  @GET
  @Path("gson")
  @Produces("application/json")
  public String getGson() {
    Payload object = new Payload();
    object.x = EXPECTED_NUMBER;
    String json = new Gson().toJson(object);
    System.out.println("GET   /gson     outgoing number:  "
        + object.x);
    System.out.println("GET   /gson     outgoing JSON:    " 
        + json);
    return json;
  }

  @POST
  @Path("gson")
  @Consumes("application/json")
  public void postGson(String json) {
    Payload object = new Gson().fromJson(json, Payload.class);
    System.out.println("POST  /gson     incoming JSON:    " 
        + json);
    System.out.println("POST  /gson     incoming number:  "
        + object.x);
  }

  @GET
  @Path("jackson")
  @Produces("application/json")
  public String getJackson() throws IOException {
    Payload object = new Payload();
    object.x = EXPECTED_NUMBER;
    String json = new ObjectMapper().writeValueAsString(object);
    System.out.println("GET   /jackson  outgoing number:  "
        + object.x);
    System.out.println("GET   /jackson  outgoing JSON:    "
        + json);
    return json;
  }

  @POST
  @Path("jackson")
  @Consumes("application/json")
  public void postJackson(String json) throws IOException {
    Payload object = new ObjectMapper().readValue(json, Payload.class);
    System.out.println("POST  /jackson  incoming JSON:    "
        + json);
    System.out.println("POST  /jackson  incoming number:  "
        + object.x);
  }
}

Here is the client-side JavaScript code:

[ "/gson", "/jackson" ].forEach(function(endpoint) {
  function handleResponse() {
    var incomingJson = this.responseText;
    var object = JSON.parse(incomingJson);
    var outgoingJson = JSON.stringify(object);
    console.log(endpoint + " incoming JSON: " + incomingJson);
    console.log(endpoint + " number toString: " + object.x);
    console.log(endpoint + " number toFixed: " + object.x.toFixed());
    console.log(endpoint + " outgoing JSON: " + outgoingJson);
    var post = new XMLHttpRequest();
    post.open("POST", endpoint);
    post.setRequestHeader("Content-Type", "application/json");
    post.send(outgoingJson);
  };
  var get = new XMLHttpRequest();
  get.addEventListener("load", handleResponse);
  get.open("GET", endpoint);
  get.send();
});

The results are disappointing

Here is the server-side output:

GET   /gson     outgoing number:  4611686018427387904
GET   /gson     outgoing JSON:    {"x":4611686018427387904}
POST  /gson     incoming JSON:    {"x":4611686018427388000}
POST  /gson     incoming number:  4611686018427388000
GET   /jackson  outgoing number:  4611686018427387904
GET   /jackson  outgoing JSON:    {"x":4611686018427387904}
POST  /jackson  incoming JSON:    {"x":4611686018427388000}
POST  /jackson  incoming number:  4611686018427388000

Here is the client-side output:

/gson incoming JSON: {"x":4611686018427387904}
/gson number toString: 4611686018427388000
/gson number toFixed: 4611686018427387904
/gson outgoing JSON: {"x":4611686018427388000}
/jackson incoming JSON: {"x":4611686018427387904}
/jackson number toString: 4611686018427388000
/jackson number toFixed: 4611686018427387904
/jackson outgoing JSON: {"x":4611686018427388000}

Both of our POST endpoints print the wrong number. Yuck!

We do send the correct number to JavaScript, which we can verify by looking at the output of x.toFixed() in the console. Something bad happens between when we print x.toFixed() and when we print the number out on the server.

Why is our code wrong?

Maybe there is a particular line of our own code where we can point our finger and say, "Aha! You are wrong!" Maybe it is an issue with our architecture.

There are many ways we could choose to address this problem (or not), and what follows is certainly not an exhaustive list.

“We call JSON.parse then JSON.stringify. We should echo back the original JSON string.”

This avoids the problem but is nothing like a real application. The test code is standing in for an application that gets the payload object from the server, uses it as an object throughout, then later/maybe makes a request back to the server containing some or all of the data from that object.

In practice, most applications will not even see the JSON.parse call. The call will be hidden. The front-end framework will do it, $.getJSON will do it, etc.

“We use JSON.stringify. We should write an alternative to JSON.stringify that produces an exact representation of our number.”

JSON.stringify delegates to x.toString(). If we never use JSON.stringify, and instead we use something like x.toFixed() to print numbers like this, we can avoid this problem.

This is probably infeasible in practice.

If we need to produce JSON from JavaScript, of course we expect that JSON.stringify will be involved. As with JSON.parse, most calls happen at a distance in a library rather than our own application code.

Besides, if we really plan to avoid x.toString(), we must do so everywhere. This is hopeless.

Suppose we commit to avoiding x.toString() and we have user objects that each have a numeric id field. We can no longer write Mustache or Handlebars templates like this:

<div id="user{{id}}">    {{! functionally wrong }}
  <p>ID: {{id}}</p>      {{! visually wrong }}
  <p>Name: {{name}}</p>
</div>

We can no longer write functions like this:

function updateEmailAddress(user, newEmail) {
  // Oops, we failed for user #2^62!
  var url = "/user/" + user.id + "/email";

  // Tries to update the wrong user (and fails, hopefully)
  $.post(url, { email: newEmail });
}

It is extremely unlikely that we will remember to avoid x.toString() everywhere. It is much more likely that we will forget and end up with incorrect behavior all over the place.

“We treat the number as a long literal in the POST handlers. We should treat the number as a double literal.”

If we parse the number as a double and cast it to a long, we produce the correct result in all test cases.

Such a cast should be guarded with a check similar to our verifyLongFitsInDouble(long) code from earlier. Here is a utility method that can be used for this purpose:

public static void verifyDoubleFitsInLong(double x) {
  long result = (long) x;
  if (Double.compare(x, result) != 0 || result == Long.MAX_VALUE) {
    throw new IllegalArgumentException("Overflow: " + x);
  }
}

What if the client really does mean to send us precisely the integer 4611686018427388000? If we parse it as a double then cast it to a long, we mangle the intended number!

Here it is worth considering who we actually talk to as we design our APIs. If we only talk to JavaScript clients, then we only receive numbers that fit in double because that is all our clients have. Often times these APIs are internal and the API authors are the same as the client code authors. It is reasonable in cases like that to make assumptions about who is calling us, even if technically some other caller could use our API, because we make no claim to support other callers.

If our API is designed to be public and usable by any client, we should document our behavior with respect to number precision. verifyLongFitsInDouble(long) and verifyDoubleFitsInLong(double) are tricky to communicate, so we may prefer a simpler rule...

“We permit some values of long outside of the range -253 < x < 253. We should reject values outside of that range even when they fit in double.”

In other words, perform a bounds check on every long number that we (de)serialize. If the absolute value of that number is less than 253 then we (de)serialize that number as usual, otherwise we throw an exception.

JavaScript clients may find this range familiar, with built-in constants to express its bounds: Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER.

This approach is less permissive than our verifyLongFitsInDouble(long) and verifyDoubleFitsInLong(double) utility methods from earlier. Those methods permit every number in this range and then more. Those methods permit numbers whose adjacent values are invalid, meaning the range of valid inputs is not contiguous.

Advantages of the less permissive approach include:

  • It is easier to express in documentation. verifyLongFitsInDouble(long) and verifyDoubleFitsInLong(double) would permit 255 + 8 but not 255 + 4. Understanding the reason for that is more difficult than understanding that neither of those numbers are permitted with the |x| < 253 approach.
  • If we are actually serializing numbers like 255 + 8, it is likely that we are trying serialize nearby numbers that cannot be stored in double. Permitting the extra numbers may only mask the underlying problem: this data should not be serialized into JSON numbers.

“We encode a long as a JSON number. We should encode it as a JSON string.”

Encoding the number as a string avoids this problem.

Twitter provides string representations of its numeric ids for this reason.

This is easy to accomplish on the server. JSON serialization libraries provide a way to adopt this convention without changing the field types of our Java classes. Our Payload class keeps using long for its field, but any time the server serializes that field into JSON, it surrounds the numeric literal with quotation marks.

How viable is this approach for the client? If the number is only being used as an identifier—passed between functions as-is, compared using the === operator, used as a key in maps—then treating it as a string makes a lot of sense. If we are lucky, the client-side code is identical between the string-using and number-using versions.

If the number is used in arithmetic or passed to libraries that expect numbers, then this solution becomes less practical.

“We use JSON as the serialization format. We should use some other serialization format.”

The JSON format is not to blame for our problems, but it allows us to be sloppy.

When we use JSON we lose information about our numbers. We do not lose the values of the numbers, but we do lose the types, which tell us the precision.

A different serialization format such as Protobuf might have forced us to clarify how precise our numbers are.

“There is no problem.”

We could declare that there is no problem. Our code breaks when provided with obscenely large numbers as input, but we simply do not use numbers that large and we never will. And even though our numbers are never this large, we still want to use long in the Java code because that is convenient for us. Other Java libraries produce or consume long numbers, and we want to use those libraries without casting.

I suspect this is the solution that most people choose (conscious of that choice or not), and it is often not a bad solution. We really do not encounter this problem most of the time. There are other problems we could spend our time solving.

Numbers smaller in magnitude than 253 do not trigger this problem. Where are our long numbers coming from, and how likely are they to fall outside that range?

Auto-incrementing primary keys in a SQL database
Will we insert more than 9,007,199,254,740,992 rows into one table? Knowing nothing at all about our theoretical application, I will venture a guess: "No."
Epoch millisecond timestamps
253 milliseconds has us covered for ±300,000 years, roughly. Are we dealing with dates outside of that range? If we are, perhaps epoch milliseconds are a poor choice for units and we should solve that problem with our units first.
Randomly-generated, unbounded long numbers
The majority of these do not fit in double. If we send these to JavaScript via JSON numbers, we will have a bad time. Are we actually doing that?
User-provided, unbounded long numbers
Most of these numbers should not trigger problems, but some will. The solution may be to add bounds checking on input, filtering out misbehaving numbers before they are used.

No matter what solution (or non-solution) we choose, we should make our choice deliberately. Being oblivious is not the answer.