Frontend
- HTML
- CSS
- JavaScript
- React
- Redux
- Next.js
- SCSS
- Ant Design
Malaysia (UTC+8) · Remote full-time and selective contract work
A little about me
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.
Tech stack & skills
The tools I use to build, ship, and support production software.
Engineering initiatives at SiteGiant
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.
Successfully led the migration of our Enterprise SaaS from Laravel 12 to Laravel 13, completing the upgrade smoothly and without hiccups.
Selected work
Selected work at SiteGiant, with the context behind the résumé: the operational problem, my contribution, and the details behind their delivery.
Reworking how large warehouses turn incoming orders into coordinated picking waves, with speed and reliability for users and room to grow for developers.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
Aug 2023 — Present
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
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
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
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.
Bachelor of Science in Computer Science
First Class Honours
Awarded 16 June 2023 · CGPA 3.72 / 4.00
Bachelor of Science with Honours in Computer Science
First Class
Awarded 16 June 2023
Final-year project
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.
Contact