Malaysia (UTC+8) · Remote full-time and selective contract work

Wong Jack Yih Full-Stack Software Engineer

3+ years in Enterprise SaaS First Class dual awards · Malaysia & United Kingdom React & Laravel · end-to-end delivery

A little about me

The person behind the projects.

I’m Wong Jack Yih, a full-stack software engineer based in Penang, Malaysia (UTC+8). I build and support commerce and operations SaaS at SiteGiant, using React, Laravel, PHP and SQL. I’m open to remote full-time roles and selective contract work.

Originally from Sabah and currently based in Penang, I’m a software engineer with a background in data analytics. In today’s AI-driven landscape, even the most advanced models are only as effective as the data quality and infrastructure supporting them and that’s where my work focuses.

Beyond writing code, I’m deeply curious about the business and economics of tech, from startup teardowns to venture strategy. While my long-term vision is to launch my own software business, right now I’m focused on tackling complex engineering problems, expanding my worldview, and seeking remote roles that allow me to collaborate with great teams while staying close to family back in Sabah.

Connect with me on LinkedIn

Tech stack & skills

Technical skills at a glance.

The tools I use to build, ship, and support production software.

Frontend

  • HTML
  • CSS
  • JavaScript
  • React
  • Redux
  • Next.js
  • SCSS
  • Ant Design

Backend

  • Python
  • PHP
  • Laravel
  • Eloquent
  • REST APIs
  • Queues & scheduling
  • Laravel Horizon

Databases & data

  • MySQL
  • SQL & CTEs
  • Redis
  • MongoDB
  • Schema migrations
  • Query optimisation

DevOps & CI/CD

  • Docker
  • Kubernetes
  • AWS CodeBuild
  • AWS CodeDeploy
  • Git
  • Node.js tooling
  • Webpack

Cloud & integrations

  • AWS S3
  • Webhooks
  • WHMCS
  • Marketplace APIs
  • Payment gateways
  • POS integrations

Testing & reliability

  • Playwright
  • TypeScript testing
  • Sentry
  • Data validation
  • Production debugging

Engineering initiatives at SiteGiant

Improving how we build.
Keeping the platform moving.

Two features. Two local environments.

Led the use of Docker alongside our local Laragon setup to run two separate frontend and backend instances. This lets us test and iterate on two different features in parallel, without repeatedly stashing changes and switching branches.

Docker · Laragon · Parallel feature development

Laravel 12 → 13, without disruption.

Successfully led the migration of our Enterprise SaaS from Laravel 12 to Laravel 13, completing the upgrade smoothly and without hiccups.

Laravel · Enterprise SaaS · Platform migration

Selected work

Systems with a real job to do.

Selected work at SiteGiant, with the context behind the résumé: the operational problem, my contribution, and the details behind their delivery.

01 / Warehouse operations

A warehouse rule engine built for scale

Reworking how large warehouses turn incoming orders into coordinated picking waves, with speed and reliability for users and room to grow for developers.

React · Laravel · MySQL · Redis

The problem

Merchants with multi-storey or multi-location warehouses use templates of configurable rules to decide which orders to process together, and which to prioritise. At the heavier end, that meant more than 20,000 orders a day and 100 rule templates to process.

My contribution

I rewrote the core from in-memory array processing into a query-based rule engine. My ownership covered the processing workflow, scheduling interface, staged rollout, and ongoing production support.

Observed processing time: In my measurements, the longest run was 4 minutes 15 seconds before the rewrite and 8 seconds in V2. These are observed maxima, rather than a controlled comparison of identical workloads.

How orders move through the warehouse rule engine, from configurable rules to coordinated picking waves.

Animated walkthrough of warehouse rule evaluation and the data flow that turns incoming orders into picking waves.

Chapters
  1. From parcels to picking waves
  2. The original in-memory engine
  3. Repeated scans and the timeout
  4. Saving the run state
  5. One SQL query per template
  6. Testing and staged rollout
  7. Before and after
Video options
Open video in a new tab

Read the delivery story

Why a rewrite was needed

The original approach loaded orders into memory and applied complex array manipulation. Both order volume and the number of templates added pressure. For some high-volume merchants, processing reached the three-minute request timeout.

The central engineering decision

I moved rule evaluation into database queries so the engine could select and group the relevant orders without loading the entire order set into application memory. The rewrite needed to preserve the meaning of user-defined rules while changing how they were executed.

What changed

The result was faster, more reliable wave processing for users and a more scalable basis for developers to extend the rules. I combined the new engine with saved workflow state, scheduled background processing, and a staged rollout to support the transition.

After release

I continued diagnosing live issues involving order visibility, grouping behaviour, and queries at merchant data volumes. That support work formed part of my ownership of the feature, alongside its initial delivery.

02 / Trust and safety

A fraud signal shared across merchants, without revealing who reported

I designed and built a buyer blacklist that shows a merchant how many other merchants on the platform have flagged the same marketplace buyer, and for which reasons, without ever revealing who. Merchants' data sits on separate database servers that cannot see each other, so I put the shared registry on the one service they can all reach, kept it in step with background jobs that always settle on each store's own data, and served the counts through a cache.

Laravel (two services) · MySQL · Redis · Signed service-to-service calls · React

The problem

Marketplace sellers, especially those offering cash on delivery, lose money to buyers who refuse parcels, abuse returns and refunds, never pay, use stolen cards or make threats. A merchant who has been burnt by a buyer knows something that every other merchant selling to that buyer would want to know. Sharing it is not simple. The platform's merchants are spread across separate database servers that cannot query each other, so there is no single place to count reports. Merchants may learn how many others reported a buyer, but never which merchants did or what they wrote. And none of it may slow down or block the order work merchants do all day.

My contribution

I designed the feature and built it end to end across the seller center, the main API and the central auth service, between July and October 2026. That covers the merchant screens and order tags, capturing buyer identity during Shopee, TikTok and Lazada order syncs, the cross-merchant registry and its signed endpoints, the jobs and cache that connect the two services, and clean-up when a store is deleted or reset. I also wrote the design documents in all three repositories and 23 Playwright tests that are now part of the team's regression suite.

A one-minute walkthrough: what a merchant sees on a flagged buyer, then how the count stays correct across database servers that cannot see each other.

Animated diagram of an order tagged Blacklisted, the reason picker showing other merchants' reports, and tags matched on five screens, followed by the backend: separate database servers, a central registry, retrying jobs, the delete-then-queue rule, a shared rate budget, a cache and signatures bound to their endpoint.

Chapters
  1. A shared warning for merchants
  2. Reports before a decision
  3. Live tags across five screens
  4. Separate database servers
  5. One central registry
  6. Retrying background jobs
  7. Delete first, then queue
  8. A shared rate budget
  9. Redis as a cache
  10. Endpoint-bound signatures
  11. What it adds up to
Video options
Open video in a new tab

Read the delivery story

What merchants get

A merchant blacklists a buyer with one of eight fixed reasons, such as refused cash on delivery, repeated returns or card fraud, plus an optional note, and can do it straight from an order. From then on a red Blacklisted tag appears on that buyer's orders in the order list, order detail, order processing, picking waves and returns, and the order list, order processing and returns can filter by it. Opening a blacklisted buyer shows how many other merchants reported the same buyer, with a breakdown by reason, and the same figures appear when a merchant is about to blacklist someone from an order. The tag is a warning only. It never holds, blocks or cancels an order, and a merchant's tags come only from their own list, never from other merchants' reports.

Who the buyer is

A buyer is identified by marketplace storefront, such as Shopee Malaysia, and buyer ID together, because buyer IDs are only unique within a marketplace. Orders did not keep that ID, so I added it to the order write that every Shopee, TikTok and Lazada sync already performs. Older Shopee and TikTok orders are filled in from stored data. For an order that still has none, the system fetches it from the marketplace again and saves only the buyer ID, with none of a full re-sync's side effects on status, shipments or totals.

Tags that cannot go stale

I did not store a blacklisted flag on orders. The tag is matched live when orders are read, against the store's own list. Adding a buyer flags all of their past and future orders at once, and removing one clears them, with no background job to update orders and no flag to drift out of date. The cost is that every screen returning orders must work the tag out for itself. The order list does it with a join, which the blacklist's unique key limits to one match per order, and the package and returns screens look up every buyer on the page in one query per storefront instead of one per order.

The central engineering decision: where the count lives

The count needs one place that sees every merchant, and the central auth service is the only service every database server can reach. I put the registry there as a set of rows, one per reporting store and buyer, rather than as a counter. That one choice makes reporting safe to repeat: a retried or duplicate report updates the same row, and editing a reason refreshes it without moving the count. Removing a buyer deletes a row, removing a store deletes its rows, and a count can leave out the merchant who is asking, which a bare counter could not do. A count request takes up to 100 buyers and returns totals and a per-reason breakdown, never the stores behind them. The main API checks reasons against its fixed list, while the auth service checks only their format, so a new reason ships without redeploying a service that runs on a different release cycle.

Keeping two services in step

Reports travel to the registry in background jobs, so a merchant's action always succeeds locally, even when the auth service is down. A failed call is retried every five minutes until five failures or two hours have passed, and then reported to Sentry. The jobs re-read the store's own data when they run, so a delayed retry always settles on the current truth: a report retried after the buyer was removed does nothing, a retry after an edit sends the newest reason, and a removal is skipped if the buyer was blacklisted again in the meantime. That depends on one ordering rule: the local row is deleted before the removal job is queued. The other way round, a fast worker could still see the row, conclude the buyer had been blacklisted again and skip, leaving a stale report that inflates every other merchant's count. The auth service allows 100 calls a minute per endpoint from each IP address, and every merchant on a database server shares that server's address, as could every server if they all leave through one gateway. So the jobs on every server draw from one shared budget of 90 calls a minute per endpoint, held in the Redis that all servers share. A job over budget waits and tries again, and nothing is lost. After an outage, a command replays a server's reports, with a dry-run option, and draws on the same budget.

Why a cache, not a Redis registry

Other merchants' counts appear in two places: a blacklisted buyer's details, and the screen for blacklisting a buyer from an order. To stop those views calling the auth service every time, I first moved the registry itself into the Redis that every server shares. I replaced that within a day. Redis cannot be the only copy: an evicted or flushed key leaves nothing to rebuild that buyer from, and a registry of every buyer ever reported only grows. So the database stayed the source of truth and Redis became a cache in front of it. Each buyer has one hash with a field per merchant who has viewed them, because each merchant's count leaves that merchant out. Each field carries its own timestamp and is trusted for 24 hours, and the hash expires a day after its last write, so only recently viewed buyers are held. When the registry accepts a change, the job clears that buyer's hash, so the figures are normally seconds old. A flushed Redis just means a few misses. If the auth service stops answering, a 30-second marker stops every server from calling it, and the response says the figures are unavailable rather than reporting zero.

Signed calls, bound to their endpoint

The platform already had a signed server-to-server link, built by a colleague, with timestamps, single-use nonces and a 60-second window. I added the registry's four endpoints to it: report, remove, count, and purge a store. Each one signs every field it sends, and the auth service checks the signature before it validates any input, so a caller without the secret only ever sees "unauthorised" and cannot probe the rules through validation errors. In review, a colleague pointed out that the signed text did not name the endpoint, so in some cases a signature made for one endpoint could be accepted by another. I put the endpoint name into every signed string, for the registry's endpoints and for the link's older endpoint too. The two services deploy separately, so the auth service accepts both forms during the rollout and logs every old-form call. The old form goes once those logs stop.

Details that mattered

When a store is deleted or reset, its reports must leave the registry too, or they keep counting towards other merchants' totals. The purge job deletes the store's reports and then re-reports every entry the store has at that moment. A reset store can start blacklisting buyers again before a delayed purge runs, and a plain purge, or a plain retry of a failed one, would delete those new reports as well.

What it adds up to

Merchants get a platform-wide warning about a buyer before they ship, shared only as numbers. Tags are always current and never depend on another service. Reports stay correct through retries, edits, outages and store resets, and stay under the auth service's limits however many database servers send them.

03 / Warehouse operations

Packing evidence recorded in the browser, CCTV included

I built and own a packing-evidence feature that records video and photos of each parcel straight from the browser, with the order's details burnt into the footage. When merchants brought their CCTV in through a virtual camera and the recordings came out choppy, I traced it to two clocks running out of step and rebuilt the capture loop so it records exactly one frame for every frame the camera delivers, switched on only at the stations that need it.

React · Canvas and MediaRecorder APIs · Laravel · MySQL · S3

The problem

Marketplace sellers lose money to "wrong item", "missing item" and "empty parcel" claims, and marketplaces settle those claims on evidence the seller usually does not have. Merchants needed proof that each parcel was packed correctly, tied to the right order, using cameras they already own: a USB webcam, or the CCTV already watching the packing table, with nothing extra to install. Stores that brought CCTV in through a virtual camera then found their recordings stuttered, and choppy footage is poor evidence.

My contribution

I own the feature from design through production support: the shared capture component used at packing, outbound scan-and-print and returns, the burnt-in overlay, recording driven by barcode scans, photo upload to the proof log and buyers' chats, and the settings behind all of it. In September 2026 I diagnosed the CCTV judder and shipped CCTV mode across the frontend and the API. Earlier in the year I fixed a recording-lag problem on packing stations.

A 38-second walkthrough: how packing evidence is captured in the browser, why CCTV footage stuttered, and the frame-by-frame fix.

Animated diagram of the in-browser capture pipeline, then a timing chart showing a virtual camera's uneven frames against a fixed-rate recorder, then the fix that records one frame per camera frame.

Chapters
  1. Packing evidence in the browser
  2. The capture pipeline
  3. CCTV through a virtual camera
  4. Two clocks and choppy footage
  5. One recorded frame per camera frame
  6. Safe rollout and fallbacks
  7. Before and after
Video options
Open video in a new tab

Read the delivery story

How the capture works

The camera feed is drawn onto a canvas, and the order's details are drawn into the same pixels: date and time with the time zone, tracking number, order number, package number, marketplace order ID, and product name, SKU and quantity, each one switchable per store. Because the details are part of the image, they cannot be separated from the footage. The canvas is streamed into the browser's MediaRecorder, and the video is saved to the packing PC. Photos come from the same canvas, up to three per order. They are uploaded and listed in the Parcel Proof Log, and can be sent to the buyer's Shopee, Lazada or TikTok chat. Recording is hands-free: scanning an order starts it, and scanning the next order saves it and starts the next one. The overlay values are frozen at the moment of saving, so a fast re-scan cannot stamp the next order's details onto the previous order's photos. One component serves all three stations, each with its own settings.

Why CCTV footage stuttered

Some merchants already have CCTV over the packing table and bring it into the browser with OBS Virtual Camera, which the browser treats as an ordinary webcam. Only those stations reported choppy video. The cause was in the recorder. The canvas was repainted on the browser's display loop, and the capture stream sampled it on its own fixed timer of 30 frames a second. A USB webcam delivers frames on a steady hardware clock, so its timing stays close to that timer. A virtual camera pushes frames from a software render loop that stutters under load. Against a fixed timer, some of its frames were recorded twice and others were never recorded at all, and that plays back as judder.

The central engineering decision

I made the camera the clock. In CCTV mode the capture stream is created with a frame rate of zero, so it has no timer of its own and only emits a frame when it is told to. The paint loop is driven by requestVideoFrameCallback, which fires once for each frame the camera actually delivers. Each callback draws that frame and the overlay onto the canvas, then calls requestFrame() on the capture track. One camera frame goes in and one recorded frame comes out, however uneven the camera's timing is.

Keeping it to the stations that need it

The new path runs only when two things are true: the store has turned on CCTV mode, and the selected camera's name matches one of 15 known virtual-camera names, such as OBS, ManyCam and DroidCam. Stations with USB webcams keep exactly the path they had. Camera names stay blank until the browser has camera permission, so when CCTV mode is on the component opens the camera once to read them; if that fails, CCTV mode stays off for the session instead of guessing. Browsers without requestVideoFrameCallback fall back to the display loop. Virtual cameras report their canvas size, usually 1080p, rather than what the CCTV actually produces, so the browser would have encoded 1080p from a 720p feed; CCTV mode caps them at 720p. When a re-render swaps the camera's video element, the loop is stopped and restarted so that no callback stays attached to the old element.

Earlier: the recording-lag fix

In February 2026 packing stations reported lag while recording. The render loop never stopped, and every recording session created a new canvas capture stream, so streams piled up and dragged the frame rate down with each session. I rewrote it as an explicit start and stop loop, reused one capture stream per camera and released it when the camera changed or the screen closed. I also kept the recorder in a ref so that stopping a recording never reads a stale value. That work is the base CCTV mode builds on.

What changed

CCTV-fed stations now record every frame the camera delivers, once, instead of repeating and skipping frames. The recorder follows the camera's clock instead of its own timer. Virtual cameras are capped at the resolution the CCTV really produces. Stations with USB webcams are untouched, and browsers without frame callbacks fall back safely.

The wider feature

CCTV mode is the latest of several increments I delivered on the same feature: product name, SKU and quantity on the overlay (April 2026), automatic sending of packing photos to buyers' marketplace chats with a resendable log and 90-day media clean-up (June 2026), and a save-only mode that keeps evidence even when a store does not send it to buyers (August 2026). It detects which resolutions each camera really supports, is covered by Playwright tests that run against simulated cameras, and is translated into five languages.

Experience

From data and quality work to full-stack delivery.

Aug 2023 — Present

Full-Stack Software Engineer

SiteGiant · E-commerce SaaS platform

Build and maintain features across merchant interfaces, APIs, storefronts, and integrations. Alongside end-to-end feature delivery and production support, I led our Laravel 12 → 13 migration and introduced parallel Docker environments for local feature development.

Nov 2021 — May 2022

Data Engineer Intern

Hannover Re

Developed and maintained ETL pipelines for client data, investigated anomalies, and tested data integrity. I documented findings and code changes in Jira and coordinated with departments, clients, and data brokers, including German colleagues. The internship was extended from four months to six.

Aug 2021 — Nov 2021

Software Tester

Hilti · ON!Track

Joined Hilti’s student tester programme during my semester break, working remotely during the pandemic on ON!Track’s web and mobile applications. The platform helps construction teams keep track of tools and equipment across jobsites, including their locations, who is responsible for them, and upcoming maintenance. I documented failure points and test findings in Jira and Confluence, collaborating with teams in Malaysia, India, and Germany.

Education · Malaysia & United Kingdom

One programme.
Two First Class awards.

I earned First Class Computer Science awards from APU (Malaysia) and Staffordshire University (UK), specialising in Data Analytics. Each university issued its own degree certificate and transcript through the dual-award programme.

Malaysia · Awarding & teaching institution

Asia Pacific University of Technology & Innovation

Bachelor of Science in Computer Science

First Class Honours

Awarded 16 June 2023 · CGPA 3.72 / 4.00

United Kingdom · Awarding institution

Staffordshire University

Bachelor of Science with Honours in Computer Science

First Class

Awarded 16 June 2023

Final-year project

Real-time American Sign Language recognition

I trained and compared LSTM and CNN models in Python, then integrated the selected model into a React application for real-time American Sign Language interpretation.

Python · LSTM · CNN · React

Contact

Open to conversations about remote engineering roles.

Contact methods

Message me on WhatsApp @WongJackYih

You’re welcome to email or WhatsApp me anytime. I work full-time from 9:30 am–6:30 pm, Malaysia time (UTC+8), so replies may take a little longer during those hours. For a call, send me a message and I’d be happy to arrange a time outside my working hours that suits us both.

Contents

On this page.

Currently reading: Introduction

Download résumé PDF · 2 pages

Let’s talk · Malaysia (UTC+8)

Start a conversation.

Have a role or project in mind? Send me a short introduction and we can arrange a time to talk.

Contact methods

Message me on WhatsApp @WongJackYih

You’re welcome to email or WhatsApp me anytime. I work full-time from 9:30 am–6:30 pm, Malaysia time (UTC+8), so replies may take a little longer during those hours. For a call, send me a message and I’d be happy to arrange a time outside my working hours that suits us both.