.toLocaleString() is too slow. Here's how to improve it.

·

4 min read

Is your cloud server instance running on the UTC timezone and do all users, and stakeholders belong to a different timezone? Or maybe you frequently bulk convert dates into a different timezone just for fun? I landed on a similar problem at work while creating automated reports. Here's how I improved performance by 40x.


The Problem.

  1. Our server ran a daily CRON to deliver a rolling list of all requests initiated by all customers. This report had multiple timestamps that had to be delivered in Asia/Kolkata timezone which is where our company operates from.

  2. We created a function that converts these date objects to a readable date string in the local timezone. This function used .toLocaleString() to achieve this.

     const formatDate = (d) =>
         d.toLocaleString('en-IN', {
             timeZone: 'Asia/Kolkata',
             year: 'numeric',
             month: 'numeric',
             day: 'numeric',
             hour: '2-digit',
             minute: '2-digit',
         })
    
  3. Converted the JSON into an xlsx file which was sent over to stakeholders for review.

  4. After operating for ~1 year, server load increased significantly while generating this report as it was a rolling list containing all historic cases.

  5. The report had 10 date conversions for every data point and about 10K data points at this moment. This meant the date conversion function had to run for 10 * 10000 times.

  6. On profiling this report we found the majority of the resources and time was spent on converting these dates to the required format and timezone.

  7. This was quite sad because we assumed that it was just a JS function - no room for performance improvements.

  8. I also discovered that we were also not the first ones encountering this problem (obviously), I could find Salesforce and Yahoo going through the same issue at scale.


The Solution.

  1. My initial approach was just to install a new npm package date-fns-tz (so naive, I was probably a fool a month ago). This did improve performance a lot but installing another package just for this single use-case seemed like an overkill.

  2. Diving deep into the working of .toLocaleString() and also this GitHub issue, I found that .toLocaleString() internally instantiates the Intl.DateTimeFormat object and then does the date conversion.

  3. Due to the Intl.DateTimeFormat object creation on every run of the function - it was super slow.

  4. So creating this instance in advance and caching it to use every time would improve performance a lot.

  5. Since we only needed to do these date timezone transformations just for one timezone Asia/Kolkata, this looked like a good solution to proceed with.

  6. The code -

     const formatter = new Intl.DateTimeFormat('en-IN', {
         timeZone: 'Asia/Kolkata',
         year: 'numeric',
         month: 'numeric',
         day: 'numeric',
         hour: '2-digit',
         minute: '2-digit',
     })
    
     const formatDate = (d) => formatter.format(d)
    
  7. The performance improvements this offered were phenomenal - as I'll discuss in the next section.


The Proof.

Let me show you some metrics if you aren't convinced yet.

  1. I wrote a small script that generates random date objects and tracked the performance of both the different ways - using .toLocaleString() and caching the formatter instance. 100K seemed like a good number as this was the current scale we were operating at.

     const totalDates = 100000
     function randomDate(start, end) {
         return new Date(
             start.getTime() + Math.random() * (end.getTime() - start.getTime())
         )
     }
     const datesArr = []
     for (let i = 0; i < totalDates; i++) {
         datesArr.push(randomDate(new Date('1970-01-01Z00:00:00:000'), new Date()))
     }
    
  2. The two different functions we'll be using, very aptly named slowFormatDate() and fastFormatDate()

     const slowFormatDate = d =>
         d.toLocaleString('en-IN', {
             timeZone: 'Asia/Kolkata',
             year: 'numeric',
             month: 'numeric',
             day: 'numeric',
             hour: '2-digit',
             minute: '2-digit',
         })
    
     const formatter = new Intl.DateTimeFormat('en-IN', {
         timeZone: 'Asia/Kolkata',
         year: 'numeric',
         month: 'numeric',
         day: 'numeric',
         hour: '2-digit',
         minute: '2-digit',
     })
    
     const fastFormatDate = (d) => formatter.format(d)
    
  3. Then I simply iterate over the date array twice - for both the different functions. And time it using our good old console.time() (what a lifesaver, who wants to use profilers anyway).

     console.time('slow')
    
     datesArr.forEach((d) => slowFormatDate(d))
    
     console.timeEnd('slow')
    
     console.time('fast')
    
     datesArr.forEach((d) => fastFormatDate(d))
    
     console.timeEnd('fast')
    
  4. Here are the results I got when I ran this - ~32X performance improvements. (This went up to ~40X on some tests)

     slow: 7.278s
     fast: 224.631ms
    

Also all of this could've been ignored if I just listened to my senior engineer and read MDN Docs for .toLocaleString() for a change instead of hunting the web for answers.

Fin.