KISS: Hand crafted JSON is NOT faster than ObjectMapper

While going through some code earlier today, I encountered a method that attempted to escape quotes and back slashes in a poor way. The author of that method presumably thought that it’d be way faster than Jackson’s ObjectMapper.

Here’s what they wrote:

public static String escapeString(Object o) {
if (o == null) {
return null;
String str = o.toString();
if (str.contains("\\")) {
str = str.replace("\\", "\\\\");
if (str.contains("\"")) {
str = str.replace("\"", "\\\"");
return str;
view raw hosted with ❤ by GitHub

This produced illegal JSON strings, especially when the input string had new lines and carriage returns in it.

Here’s what I replaced it with:

private static final ObjectMapper OM = new ObjectMapper();
public static String escapeString(Object o) {
if (o == null) {
return null;
try {
// The string is automatically quoted. Therefore, return a new string that
// doesn't contain those quotes (since the caller appends quotes themselves).
val bytes = OM.writeValueAsBytes(o.toString());
return new String(bytes, 1, bytes.length2, StandardCharsets.UTF_8);
} catch (JsonProcessingException e) {
return "";
view raw hosted with ❤ by GitHub

At first, I was uncertain about the efficiency of either approaches. Let’s JMH it:

private static final ObjectMapper OM = new ObjectMapper();
private static final String STR = "hello world – the quick brown fox jumps over "
+ "the lazy dog\r\n\r\nand here's "
+ "a random slash\\, and some \"s";
public void jsonStringSerialization(final Blackhole blackhole) throws Exception {
byte[] obj = OM.writeValueAsBytes(STR);
blackhole.consume(new String(obj, 1, obj.length2, StandardCharsets.UTF_8));
public void jsonStringManual(final Blackhole blackhole) {
String str = STR;
if (str.contains("\\")) {
str = str.replace("\\", "\\\\");
if (str.contains("\"")) {
str = str.replace("\"", "\\\"");
view raw hosted with ❤ by GitHub

The results were quite astounding. I hadn’t expected something like the following:

Benchmark                            Mode  Cnt        Score   Error  Units
Benchmarks.jsonStringManual         thrpt    2    83301.447          ops/s
Benchmarks.jsonStringSerialization  thrpt    2  4171309.830          ops/s

There must be something wrong, right? Perhaps it’s because of the static string. Let’s replace our static string with a random one generated for each iteration:

private static final ObjectMapper OM = new ObjectMapper();
public void jsonStringSerialization(final Blackhole blackhole) throws Exception {
byte[] obj = OM.writeValueAsBytes(randomString());
blackhole.consume(new String(obj, 1, obj.length2, StandardCharsets.UTF_8));
public void jsonStringManual(final Blackhole blackhole) {
String str = randomString();
if (str.contains("\\")) {
str = str.replace("\\", "\\\\");
if (str.contains("\"")) {
str = str.replace("\"", "\\\"");
private static String randomString() {
return RandomStringUtils.random(75,
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z',
'\\', '\r', '\n', '\"', '\'', ' ');
public void randomString(final Blackhole blackhole) {
view raw hosted with ❤ by GitHub

Here’s the JMH report:

Benchmark                            Mode  Cnt        Score   Error  Units
Benchmarks.jsonStringManual         thrpt    2   133432.951          ops/s
Benchmarks.jsonStringSerialization  thrpt    2  1535802.541          ops/s
Benchmarks.randomString             thrpt    2  2871443.990          ops/s


KISS. Premature optimisations are harmful. Not only can they introduce bugs, but they could be slower than the industry standard. Benchmark everything carefully.

Mac M1/M2: Keyboard Brightness Keys + Remap § to ~

I recently moved from the US layout to the UK layout for my shiny new MacBook Air M2. I’d get used to the keys, however, my work MacBook is still on the US layout. Therefore, I decided to:

  1. Remap the section key § to tilde/back-tick (I use the tilde and back tick keys a lot)
  2. Remap the original tilde key to the left shift, so that I don’t accidentally hit the tilde in the wrong place
  3. Bring back the keyboard brightness keys by remapping the Siri (F5) + DND keys (F6)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
<plist version="1.0">
"HIDKeyboardModifierMappingSrc": 0xC000000CF,
"HIDKeyboardModifierMappingDst": 0xFF00000009
"HIDKeyboardModifierMappingSrc": 0x10000009B,
"HIDKeyboardModifierMappingDst": 0xFF00000008
"HIDKeyboardModifierMappingSrc": 0x700000064,
"HIDKeyboardModifierMappingDst": 0x700000035
"HIDKeyboardModifierMappingSrc": 0x700000035,
"HIDKeyboardModifierMappingDst": 0x7000000E1

Save the contents above to ~/Library/LaunchAgents/com.judepereira.keyremapping.plist, and logout/login :)

That’s all!


Cloudflare Zero Trust Gateway and Net Neutrality

TL;DR: This post isn’t merely a rant after Cloudflare’s recent outages, but rather meant to serve as an after-thought: is Cloudflare ruining the entire concept of a distributed internet? Is it on a path to violate Net Neutrality?

Let’s analyse it a little bit.

What is it?

Cloudflare’s Zero Trust Gateway routes all internet traffic from your devices such as your laptop, via Clouldflare’s internet backbone. Most likely, your personal device wouldn’t be connected to the zero trust Gateway, since it’s almost always deployed by enterprise companies.

What’s wrong with it?

The internet is de-centralised – no single authority can take it down, nor can control traffic across it. In its literal sense, “internet” means interconnected networks. This means that you’re reading this post through a bunch of networks that eventually connect to the host where this blog is hosted, in Amsterdam via Digital Ocean. The source of your connection could be anything – from a 5G capable device, a wired network connection being shared in a building, your ISP, etc. The list goes on an on.

However, Cloudflare Zero Trust Gateway routes ALL your traffic through a bunch of proxies that Cloudflare exclusively controls:

Yes, Cloudflare actually runs an active MITM attack, decrypting all your TLS data.
Fuck off Cloudflare!

And now we get to the real problem: when Cloudflare deploys a buggy version of their software, to the end user, that is to you, it appears as if the entire internet is down. You can’t get work done, nor can you do anything productive with that €3000 MacBook you’ve just bought. Surprisingly, this happens more often than not, especially in recent times.

In the future, if Cloudflare Zero Trust Gateway captures any significant market share (hopefully unlikely), they can suddenly start to make decisions that violate Net Neutrality.

Net Neutrality

If you’ve never watched John Oliver’s take on Net Neutrality, watch it here. ISPs have done such things in the past, and have managed to get away with it, albeit far fewer of those severely offending ISPs exist today (if somebody finds a valuable source for or against this, please post it in the comments). Of course, the giants still continue to live on.

Final Notes

In order to preserve Net Neutrality, the openness of the internet, no single company should own a significant chunk of the internet traffic. If your company is pushing for Cloudflare Zero Trust Gateway, push your IT administrator to read this post, and help point them in the right direction.

Getting the AWS CLI to accept Cloudflare WARP’s root certificate

When we moved to Cloudflare WARP at CleverTap, everything worked as expected, except for the AWS CLI:

SSL validation failed for [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1125)

The problem with Cloudflare WARP is that it’s the equivalent of Charle’s Proxy – presenting its own certificate for everything. While this works for almost everything, it doesn’t work for tools that use a well known and publicly trusted CA bundle.

Moreover, AWS’ CLI v2 is built with Python, which apparently doesn’t even have access to macOS’ Keychain. So, although that Cloudflare WARP certificate is installed in Keychain, tools from JetBrains and AWS’ very own CLI will refuse to work.

The solution? Download a .crt from Cloudflare’s documentation, convert it to a .pem (using Keychain), and then add it to your CA bundle (in my case, the default one installed by Homebrew).

Preparing the certificate

Download the certificate from here. Next, open it up in Keychain:

Right click on that entry, to see the Export option:

Export it as a PEM:

Hit Save.

Installing the certificate

And now to the tricky part. The CA bundle that AWS uses for its CLI is a mystery. However, it does allow one to override that, by using an environment variable. So, we want that CLI to trust the usual set of root certificates, along with Cloudflare’s. OpenSSL installs a decent set of trusted root certificates, which we can append to:

$ cat Cloudflare.pem >> /Users/jude/bin/Homebrew/etc/openssl/cert.pem

All that’s remaining is to tell the AWS CLI to use it:

$ export AWS_CA_BUNDLE=/Users/jude/bin/Homebrew/etc/openssl/cert.pem

And voila! It starts working magically!

Lessons learnt from ESP NOW

For those of you who aren’t aware of ESP NOW, it’s a communication protocol developed by Espressif for their ESP modules.

What began as a simple “hook up my plant lights to HomeKit”, turned into a massive home grown firmware, but more about that in another post.

I’ve successfully setup a bunch of ESP 8266 modules to talk to one “hub” ESP 8266 module, which then talks to my WiFi network and exposes everything as HomeKit accessories.

While doing so, I learnt:

  • It’s not possible to go back to light sleep with GPIO interrupt enabled after sending a payload via ESP NOW.
  • ESP NOW really really likes WiFi channel 1. On the transmitter side, setting the channel via esp_now_add_peer() doesn’t seem to be a reliable way of having it transmit on the desired channel. u/cperiod on Reddit confirmed this. His/her solution was to spawn an AP temporarily to switch the channel:
  • Since the channel needs to be fixed, ensure that your hone WiFi network doesn’t jump. I configured my router to keep to channel 6 (the best for apartment) always.
  • ESP NOW payloads are usually delivered at their first attempt, but it doesn’t hurt to add an automatic retry feature in your firmware. For me, my low power motion sensors attempt up to ten times.
  • It’s incredibly efficient when it comes to range. Payloads were delivered across two walls in my apartment. No additional antenna was used, just the standard one on the Wemos D1 Mini. Somebody on Reddit claimed four walls in their apartment :)

That’s it! I’ll write a post about the entire setup + my home built plug and play firmware later.

Taming a throttled API with Dynamic Proxies in Java

Recently, at CleverTap, we’ve begun migrating some of our largest clusters to a new protocol (for starters, think ~115 instances at a time). One of the most fun things I’ve had my hands on during this migration was the AWS Systems Manager API.

When we scaled up our migrations gradually from a 10 node cluster, we were challenged with dealing with API throttling exceptions (because sure, who wouldn’t throttle their APIs?). There were two immediate solutions that hit our mind:

  1. Review every usage of the SSM client and handle the throttling exception gracefully
  2. Wrap the SSM client and handle the throttling exception transparently

Naturally, we settled for option 2. I am a big fan of hidden abstractions. So what did we do? We implemented the AWS interface in question, only to discover that we’d have to handle a ton of methods individually (obviously copy/paste). There had to be a better solution!

And then, Google did it’s thing. We discovered Dynamic Proxies. And viola! We were able to transparently handle and implement an auto retry strategy within just 14 lines!

Here’s what it looked like:

MyStubbornAPIInterface actualInstance = … // Create it however you'd create your original instance.
MyStubbornAPIInterface proxiedInstance = (MyStubbornAPIInterface) Proxy.newProxyInstance(actualInstance.getClass().getClassLoader(),
new Class[]{MyStubbornAPIInterface.class}, (proxy, method, args) -> {
while (true) {
try {
return method.invoke(actualInstance, args);
} catch (MyThrottlingException e) {
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(1, 5) * 1000L);
} catch (InterruptedException e) {

The code above can be easily adapted to various SDKs (in our case, it was the AWS SDK).

Now, all we had to do was pass around this proxied instance, and viola, the consumers of this API had no clue that the API implemented an auto retry mechanism!


Sending OTA updates over WiFi to your ESP8266

This Christmas, I added a whole bunch of lights powered by 5V power sources. My goal was to switch them on at sunset, and switch them off on sunrise, by using a MOSFET for power control :)

While I was doing this, I wanted to send OTA updates of my Lua files to the ESP8266 via WiFi. For some unknown reason, I couldn’t use’s TCP update method.

So, I ended up building my very own OTA update protocol (which turned out to be fun!). To begin, add ota.lua to your project, and invoke it using dofile("ota.lua") in your init.lua:

Send OTA updates to remotely update lua scripts on your ESP8266.
Created by Jude Pereira <>
srv = net.createServer(net.TCP)
current_file_name = nil
srv:listen(8080, function(conn)
conn:on("receive", function(sck, payload)
if string.sub(payload, 1, 5) == "BEGIN" then
current_file_name = string.sub(payload, 7), "w")
sck:send("NodeMCU: Writing to " .. current_file_name .. '\n')
elseif string.sub(payload, 1, 4) == "DONE" then
sck:send("NodeMCU: Wrote file " .. current_file_name .. "!\n")
current_file_name = nil
elseif string.sub(payload, 1, 7) == "RESTART" then
sck:send("NodeMCU: Restart!\n")
tmr.create():alarm(500, tmr.ALARM_SINGLE, node.restart)
if, "a+") then
if file.write(payload) then
sck:send("NodeMCU: Write failed!\n")
sck:send("NodeMCU: Open failed!\n")
conn:on("sent", function(sck) sck:close() end)
view raw ota.lua hosted with ❤ by GitHub

Then, to use this shiny new TCP endpoint created on your ESP8266/NodeMCU, create a wrapper shell script:

# Wrapper script for sending OTA updates to your ESP8266 running NodeMCU.
# See
for i in "$@"; do
echo "Sending $i"
echo -n "BEGIN $FILE" | nc $HOST $PORT
while read -r line; do
#echo -n "write: $line … "
if ! echo "$line" | nc $HOST $PORT | grep "ok" &>/dev/null; then
echo "Write failed! Please retry…"
exit 1
done <"$FILE"
echo -n "DONE" | nc $HOST $PORT
echo -n "RESTART" | nc $HOST $PORT
view raw hosted with ❤ by GitHub

Heads up! Replace HOST with the IP of your NodeMCU.

The wrapper script will automatically trigger a restart at the end. To use the wrapper script:

$ chmod +x
$ ./ file1.lua file2.lua init.lua

And that’s it! OTA update away!

nRF52840 – CircuitPython 5.0.0 pinout

I recently got CircuitPython running on my SparkFun nRF52840 Pro Mini.

Compared to what SparkFun says the pin mappings should be, I found them to be quite different. Perhaps they changed with CircuitPython 5.0.0?

Here’s what the pin mapping looks like, when superimposed over SparkFun’s pinout diagram:

SparkFun Pro nRF52840 Mini pinout with CircuitPython superimposed


nRF52840 – flashing the s340 v6.1.1 SoftDevice

This post is a work in progress (WIP). The result of this experiment is a success. I have flashed my SparkFun nRF52840 mini, and I’m able to run the bicycle combined speed & cadence sensor example.

Before we begin,  a big hats off to Charles, who brought support for the SparkFun board I have to the Adafruit nRF52 bootloader. Cheers Charles! I owe you a beer :) – GitHub profile, blog

Important software versions:

nRF SDK: nRF5_SDK_15.3.0_59ac345
ARM GCC: 8.2.1
s340: s340_nrf52_6.1.1
board: SparkFun Pro nRF52840 mini

Rough outline:

1. Checkout ‘s PR

2. Copy over src/linker/s140_v6.ld to src/linker/s340_v6.ld – there are zero differences between these two files

3. Patch your main.c from the checked out source to initialise the soft device with the ANT_LICENSE_KEY

diff –git a/src/main.c b/src/main.c
index 8ac1dba..2e43f49 100644
— a/src/main.c
+++ b/src/main.c
@@ -301,7 +301,7 @@ static uint32_t softdev_init(bool init_softdevice)
APP_ERROR_CHECK( sd_softdevice_enable(&clock_cfg, app_error_fault_handler) );
+ APP_ERROR_CHECK( sd_softdevice_enable(&clock_cfg, app_error_fault_handler, ANT_LICENSE_KEY) );
/*————- Configure BLE params ————-*/

view raw


hosted with ❤ by GitHub

4. Patch the Makefile to use the s340 soft device files

diff –git a/Makefile b/Makefile
index 6dbaf98..4acd319 100644
— a/Makefile
+++ b/Makefile
@@ -104,7 +104,7 @@ ifneq ($(IS_52832),)
SD_NAME = s132
SD_NAME = s140
+SD_NAME = s340
DFU_DEV_REV = 52840
@@ -275,7 +275,7 @@ CFLAGS += -DNRF52832_XXAA
CFLAGS += -DS132
CFLAGS += -DS140
+CFLAGS += -DS340
@@ -314,7 +314,7 @@ ASMFLAGS += -DNRF52

view raw


hosted with ❤ by GitHub

5. Place the contents of the s340 archive (sign up for the evaluation licence from, wait for 1 business day, and then download the s340 soft device)

$ tree lib/softdevice/s340_nrf52_6.1.1/
├── s340_nrf52_6.1.1_API
│   └── include (all header files must be under here)
└── s340_nrf52_6.1.1_softdevice.hex

view raw

hosted with ❤ by GitHub

6. Flash your nRF52840 device (double reset to enter the DFU mode)

$ make BOARD=sparkfun_pro_nrf52840_mini SERIAL=/dev/tty.usbmodem14301 dfu-flash

7. Verify

When you enter DFU mode after the above command completes, the contents of INFO_UF2.TXT must look something like the contents here:

UF2 Bootloader 0.2.10-4-g79fe6cc-dirty lib/nrfx (v1.1.0-1-g096e770) lib/tinyusb (legacy-755-g55874813) s340 6.1.1
Model: SparkFun Pro nRF52840 Mini
Board-ID: SparkFun-Pro-nRF52840-Mini
Bootloader: s340 6.1.1
Date: Jul 12 2019

view raw


hosted with ❤ by GitHub

Very important – update your app’s linker script:

Since your board now runs the s340 soft device, update the FLASH and RAM values in your app’s linker script:

# diff diff ~/developer/em/nRF5_SDK_15.3.0_59ac345/examples/ant/ant_plus/ant_bsc/bsc_tx/pca10040/s212/armgcc/ant_bsc_tx_gcc_nrf52.ld ~/developer/em/bia/src/ant_bsc_tx_gcc_nrf52.ld
< FLASH (rx) : ORIGIN = 0x12000, LENGTH = 0x6e000
< RAM (rwx) : ORIGIN = 0x20000b80, LENGTH = 0xf480
> FLASH (rx) : ORIGIN = 0x00031000, LENGTH = 0x000F4000-0x00031000
> RAM (rwx) : ORIGIN = 0x20002000, LENGTH = 0xf480

view raw


hosted with ❤ by GitHub

The new values are not black magic. They’re documented here:


  • RAM and FLASH addresses:
  • MBR and boot loader info from Nordic:
  • Reading boot loader settings:
  • Usage of MBR params:
  • Segger J-Link Mini:


Random notes below, don’t follow any of it, or execute any commands from here on out. You’ve been warned.

FLASH and RAM for s340 6.1.1:

S340- 6.1.1

Min RAM start: 0x20002000

Flash start: 0x31000


Generate boot loader settings:

(nrfutil) h2:nrfutil jude$ nrfutil settings generate –family NRF52840 –softdevice ../nRF5_SDK_15.3.0_59ac345/components/softdevice/s112/hex/s112_nrf52_6.1.1_softdevice.hex –bootloader-version 1 –bl-settings-version 1 a.hex


$ git status

h2:Adafruit_nRF52_Bootloader jude$ git status
On branch master
Your branch is up to date with 'origin/master'.
Changes not staged for commit:
(use "git add <file>…" to update what will be committed)
(use "git checkout — <file>…" to discard changes in working directory)
modified: Makefile
modified: src/main.c
Untracked files:
(use "git add <file>…" to include in what will be committed)
no changes added to commit (use "git add" and/or "git commit -a")
h2:Adafruit_nRF52_Bootloader jude$

view raw


hosted with ❤ by GitHub

Installing the Nginx Ingress Controller via Helm to a K8s cluster with RBAC enabled

A lot of posts describe how to do this, but are fairly outdated, and do not mention the last supported K8s version. Here’s a tried and tested way to do so via Helm. This has been tested on GKE, with the Kubernetes master version 1.9.7-gke.6:

    1. Create the service account for Tiller – the Helm server
      $ kubectl create serviceaccount --namespace kube-system tiller
    2. Create the cluster role
      $ kubectl create clusterrolebinding tiller-cluster-rule --clusterrole=cluster-admin --serviceaccount=kibe-system:tiller
    3. Apply the RBAC role
      1. Create tiller.yml with the following content
        kind: ClusterRoleBinding
          name: tiller-clusterrolebinding
        - kind: ServiceAccount
          name: tiller
          namespace: kube-system
          kind: ClusterRole
          name: cluster-admin
          apiGroup: ""
      2. Apply this
        $ kubectl create -f tiller.yaml
    4. Initialise Helm
      helm init --service-account tiller --upgrade
    5. Wait until the tiller-deploy service is running
      $ while ! kubectl get pod -n kube-system | grep tiller-deploy | grep Running &> /dev/null; do
        echo "Waiting for the tiller-deploy pod to be ready..."
        sleep 1
    6. Install the Nginx Ingress Controller
      helm install --name nginx-ingress stable/nginx-ingress --set rbac.create=true
    7. Have fun!

Inspired from Bitnami.

Read the ongoing issue here.

IntelliJ on steroids with G1 GC

Lately, I noticed that IntelliJ started to pause for quite some time during it’s GC cycles, and that it was very frequent when I was editing three files (over 1.2k LOC each) split vertically.

The current version of IntelliJ runs on a bundled version of Java 1.8, who’s default garbage collector is Parallel GC. While this works for most people, it didn’t for me.

After a ton of reading up on how GC works, and the fine tuning parameters for G1, I put it to use. Here’s a copy of my idea.vmoptions file:


view raw


hosted with ❤ by GitHub

There was an instant performance boost in the IDE – it was far more responsive than ever before. The pauses have disappeared, and it’s super snappy :)

Note: As a general rule of thumb, don’t increase the maximum memory allocated to the IDE beyond 2 gigabytes – it’s just not worth it.

How to tunnel all traffic from your iOS device to your own server via IPSec

TL;DR: A DigitalOcean droplet, strongSwan, and a custom Configuration Profile for iOS routes all the traffic from my iPhone via my droplet. Why? Just because I can.

Note: This setup does not require you to download Apple Configurator and switch your iPhone into Supervised mode (we will create a configuration profile by hand instead, and install it on the iPhone).

Configure strongSwan by following all the instructions here

  1. Ignore the part about configuring the firewall, we’ll do this later
  2. Ensure strongswan starts on boot via chkconfig
    chkconfig --add strongswan
    chkconfig strongswan on
    # Verify
    chkconfig --list strongswan
  3. You don’t need to install any certificates on your iPhone/iPad/Mac as we’re using a pre-shared key (PSK) instead of a certificate based client authentication mechanism

Allow traffic to be forwarded from your server by adding the two iptables rules here

Be sure to modify the network in the two iptables commands (it should match the one specified in your strongSwan config)

Save the two rules which you’ve just added

service iptables save

Open up UDP ports 500 and 4500 for your instance if required (AWS/DigitalOcean/etc)

Adapt the following Configuration Profile for your iOS device

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
<plist version="1.0">
<string>VPN Configuration</string>

view raw


hosted with ❤ by GitHub

Replace the following variables with reasonable values for your setup:

MY_PROFILE_NAME                  - Only used for display purposes
MY_DOMAIN                        - Just for scoping
MY_STRONGSWAN_SERVER_IP_ADDRESS  - Your server's IPv4 address
MY_ACCOUNT_NAME                  - See /etc/strongswan/ipsec.secrets
MY_ACCOUNT_PASSWORD              - See /etc/strongswan/ipsec.secrets
MY_INITIALS                      - Your initials (eg: JP)

Once you’ve updated the content of the XML file above, rename the file to VPNConfig.mobileconfig. Then, either AirDrop it to your iPhone/iPad, or transfer it by some other means.

Since we’re using a PSK, as soon as you install the profile, it’ll prompt you for the PSK. This can again be found in /etc/strongswan/ipsec.secrets.

All done! :)

Cheers on your newly established, always on VPN tunnel between your iOS device and your server!

The Configuration Profile was inspired from Thomas’s blog post here.

The Bombay Santa – An Exclusive Secret Santa Gathering

This year, for the very first time in Mumbai, LetsTuneup is hosting an exclusive, invite only Secret Santa gathering.

I got invited!

I was one of the firsts to be invited (yes!). We’re planning to have a fun filled, gift exchanging afternoon sometime during the Christmas week. The venue is yet to be declared. If you’re looking for an invite, tweet to LetsTuneup!

More information about this event is available here.

We’re looking forward to meeting all of you out there!